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 import sqlite3 from flask import ( Blueprint, current_app, flash, g, jsonify, redirect, render_template, request, send_file, url_for, ) 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, BUILDER_LABELS, BUILDER_OPTIONS, DAYPART_SLUG_TO_MEAL_TYPE, DEFAULT_CATEGORY_BUILDERS, DAY_TEMPLATE_NAME_SUGGESTIONS, DEFAULT_CATEGORIES, ENERGY_DENSITY_LABELS, ENERGY_DENSITY_OPTIONS, FOOD_FLAVOR_DESCRIPTIONS, FOOD_FLAVOR_LABELS, FOOD_FLAVOR_OPTIONS, FOOD_ROLE_DESCRIPTIONS, FOOD_ROLE_LABELS, FOOD_ROLE_OPTIONS, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS, ITEM_SET_NAME_SUGGESTIONS, MEAL_STYLE_LABELS, MEAL_STYLE_OPTIONS, MEAL_TYPE_LABELS, MEAL_TYPE_OPTIONS, NOTIFICATION_CHANNEL_OPTIONS, PROTEIN_PREFERENCE_LABELS, PROTEIN_PREFERENCE_OPTIONS, SUGGESTION_STYLE_LABELS, SUGGESTION_STYLE_OPTIONS, SUGGESTION_PRIORITY_LABELS, SUGGESTION_PRIORITY_OPTIONS, VISIBILITY_DESCRIPTIONS, VISIBILITY_LABELS, WEEKDAY_OPTIONS, WEEK_TEMPLATE_NAME_SUGGESTIONS, ) from .db import get_db, infer_food_flavor_profile, infer_food_profile from .images import ( allowed_image_file, save_photo_with_variants, upload_file_size_ok, ) from .push import push_is_configured, push_public_key, send_push_message main_bp = Blueprint("main", __name__) ACTIVE_STATE_OPTIONS = [ ("", "Alle aktiven"), ("home", "Zuhause"), ("idea", "Gerade nicht da"), ("unsorted", "Unsortiert"), ] KIND_FILTER_OPTIONS = [ ("", "Alles"), ("food", "Lebensmittel"), ("meal", "Mahlzeitenideen"), ] VISIBILITY_FILTER_OPTIONS = [ ("", "Alles Sichtbare"), ("shared", "Gemeinsam"), ("personal", "Persönlich"), ] VISIBILITY_FORM_OPTIONS = [ ("shared", "Gemeinsam"), ("personal", "Persönlich"), ] TARGET_USER_OPTIONS_DEFAULT = "__all__" WEEKDAY_LABELS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] PRIMARY_DAYPART_SLUGS = {"breakfast", "lunch", "dinner"} SNACK_DAYPART_SLUGS = {"morning-snack", "afternoon-snack", "late-snack"} PDF_DAYPART_LABELS = { "morning-snack": "Snack am Vormittag", "afternoon-snack": "Snack am Nachmittag", "late-snack": "Später Snack", } @main_bp.before_app_request def refresh_due_context(): endpoint = request.endpoint or "" if getattr(g, "user", None) is None: return None if request.method == "GET" and endpoint.startswith("main."): try: activate_due_shopping_needs() except sqlite3.OperationalError: current_app.logger.warning("Due shopping needs could not be activated during this request.") return None def current_household_id() -> int: return int(g.user["household_id"]) def get_dayparts() -> list: return get_db().execute("SELECT * FROM dayparts ORDER BY sort_order").fetchall() def format_weekday(day_value: date) -> str: return WEEKDAY_LABELS[day_value.weekday()] def get_household_users(active_only: bool = True): query = """ SELECT id, username, display_name, role FROM users WHERE household_id = ? """ params: list[object] = [current_household_id()] if active_only: query += " AND is_active = 1" query += " ORDER BY LOWER(COALESCE(display_name, username))" return get_db().execute(query, params).fetchall() def get_target_user_options() -> list[dict]: options = [{"value": TARGET_USER_OPTIONS_DEFAULT, "label": "Für alle"}] for user in get_household_users(): options.append( { "value": str(user["id"]), "label": user["display_name"] or user["username"], } ) return options def get_category_options(include_inactive_selected: str | None = None) -> list[str]: rows = get_db().execute( """ SELECT name FROM household_categories WHERE household_id = ? AND is_active = 1 ORDER BY sort_order, LOWER(name) """, (current_household_id(),), ).fetchall() categories = [row["name"] for row in rows] if not categories: categories = DEFAULT_CATEGORIES[:] if include_inactive_selected and include_inactive_selected not in categories: categories.append(include_inactive_selected) return categories def get_category_builder_map() -> dict[str, str]: rows = get_db().execute( """ SELECT name, builder_key FROM household_categories WHERE household_id = ? """, (current_household_id(),), ).fetchall() builder_map = {row["name"]: (row["builder_key"] or "neutral") for row in rows} for name, builder_key in DEFAULT_CATEGORY_BUILDERS.items(): builder_map.setdefault(name, builder_key) return builder_map def get_household_settings() -> dict: row = get_db().execute( """ SELECT shopping_weekday, shopping_prep_days, shopping_reminder_time FROM households WHERE id = ? """, (current_household_id(),), ).fetchone() if row is None: return { "shopping_weekday": 5, "shopping_prep_days": 1, "shopping_reminder_time": "18:00", } return { "shopping_weekday": int(row["shopping_weekday"] or 5), "shopping_prep_days": int(row["shopping_prep_days"] or 1), "shopping_reminder_time": row["shopping_reminder_time"] or "18:00", } def default_user_settings() -> dict: suggestion_style = "balanced" return { "user_id": int(g.user["id"]), "reminders_enabled": True, "push_enabled": False, "notification_channel": "in_app", "suggestion_style": suggestion_style, "energy_preference": suggestion_style_energy_preference(suggestion_style), "protein_preference": "mixed", "remind_before_shopping": True, "remind_on_shopping_day": True, "show_missing_for_upcoming_week": True, "show_planned_not_shopped": True, "remind_tomorrow_if_sparse": True, "remind_week_if_sparse": True, "push_missing_breakfast": False, "push_missing_lunch": False, "push_missing_dinner": False, "push_small_snack": False, "suggest_home_for_today": True, "remind_small_snack": False, "remind_nuts": False, "show_meal_balancing": True, "suggest_templates": True, "suggest_patterns": True, } def ensure_user_settings_row(*, commit: bool = False) -> None: existing = get_db().execute( "SELECT 1 FROM user_settings WHERE user_id = ? LIMIT 1", (g.user["id"],), ).fetchone() if existing is not None: return get_db().execute( "INSERT INTO user_settings (user_id) VALUES (?)", (g.user["id"],), ) if commit: get_db().commit() def get_user_settings() -> dict: settings = default_user_settings() row = get_db().execute("SELECT * FROM user_settings WHERE user_id = ?", (g.user["id"],)).fetchone() if row is None: return settings settings.update(dict(row)) boolean_fields = { "reminders_enabled", "push_enabled", "remind_before_shopping", "remind_on_shopping_day", "show_missing_for_upcoming_week", "show_planned_not_shopped", "remind_tomorrow_if_sparse", "remind_week_if_sparse", "push_missing_breakfast", "push_missing_lunch", "push_missing_dinner", "push_small_snack", "suggest_home_for_today", "remind_small_snack", "remind_nuts", "show_meal_balancing", "suggest_templates", "suggest_patterns", } for field in boolean_fields: settings[field] = bool(settings.get(field)) settings["notification_channel"] = settings.get("notification_channel") or "in_app" settings["suggestion_style"] = normalize_suggestion_style(settings.get("suggestion_style"), "balanced") settings["energy_preference"] = suggestion_style_energy_preference(settings["suggestion_style"]) settings["protein_preference"] = normalize_protein_preference(settings.get("protein_preference"), "mixed") return settings def parse_checkbox(name: str, default: bool = False) -> int: return 1 if request.form.get(name, "1" if default else "0") == "1" else 0 def normalize_weekday(raw: str | None, default: int = 5) -> int: if raw and raw.isdigit(): value = int(raw) if 0 <= value <= 6: return value return default def normalize_notification_channel(raw: str | None, default: str = "in_app") -> str: allowed = {value for value, _label in NOTIFICATION_CHANNEL_OPTIONS} return raw if raw in allowed else default def normalize_suggestion_style(raw: str | None, default: str = "balanced") -> str: allowed = {value for value, _label in SUGGESTION_STYLE_OPTIONS} return raw if raw in allowed else default def normalize_energy_density(raw: str | None, default: str = "neutral") -> str: allowed = {value for value, _label in ENERGY_DENSITY_OPTIONS} return raw if raw in allowed else default def normalize_base_type(raw: str | None, default: str = "neutral") -> str: allowed = {value for value, _label in BUILDER_OPTIONS} return raw if raw in allowed else default def normalize_food_flavor(raw: str | None, default: str = "neutral") -> str: allowed = {value for value, _label in FOOD_FLAVOR_OPTIONS} return raw if raw in allowed else default def normalize_food_role(raw: str | None, default: str = "base") -> str: allowed = {value for value, _label in FOOD_ROLE_OPTIONS} return raw if raw in allowed else default def normalize_suggestion_priority(raw: str | None, default: str = "normal") -> str: allowed = {value for value, _label in SUGGESTION_PRIORITY_OPTIONS} return raw if raw in allowed else default def normalize_meal_type(raw: str | None, default: str = "snack") -> str: allowed = {value for value, _label in MEAL_TYPE_OPTIONS} return raw if raw in allowed else default def normalize_meal_tags(values: list[str] | None) -> list[str]: allowed = {value for value, _label in MEAL_STYLE_OPTIONS} normalized: list[str] = [] for value in values or []: if value in allowed and value not in normalized: normalized.append(value) return normalized def encode_tag_list(values: list[str] | None) -> str: return ",".join(normalize_meal_tags(values)) def decode_tag_list(raw: str | None) -> list[str]: if not raw: return [] return normalize_meal_tags([part.strip() for part in str(raw).split(",") if part.strip()]) def normalize_protein_preference(raw: str | None, default: str = "mixed") -> str: allowed = {value for value, _label in PROTEIN_PREFERENCE_OPTIONS} return raw if raw in allowed else default def suggestion_style_energy_preference(style: str) -> str: if style == "fitness": return "low" if style == "easy": return "low" if style == "snack": return "neutral" return "neutral" def visible_clause(table_alias: str) -> str: return ( f"{table_alias}.household_id = ? " f"AND ({table_alias}.visibility = 'shared' OR {table_alias}.owner_user_id = ?)" ) def visible_params() -> list[int]: return [current_household_id(), int(g.user["id"])] def parse_week_start(raw: str | None) -> date: if raw: try: parsed = datetime.strptime(raw, "%Y-%m-%d").date() return parsed - timedelta(days=parsed.weekday()) except ValueError: pass today = date.today() return today - timedelta(days=today.weekday()) def parse_plan_date(raw: str | None, fallback: date | None = None) -> date: if raw: try: return datetime.strptime(raw, "%Y-%m-%d").date() except ValueError: pass return fallback or date.today() def normalize_visibility(raw: str | None, default: str = "shared") -> str: return raw if raw in VISIBILITY_LABELS else default def normalize_target_user_id(raw: str | None) -> int | None: if not raw or raw == TARGET_USER_OPTIONS_DEFAULT: return None if not raw.isdigit(): return None target_id = int(raw) allowed = {int(user["id"]) for user in get_household_users()} return target_id if target_id in allowed else None def save_photo(upload, current_filename: str | None = None) -> str | None: if not upload or not upload.filename: return current_filename if not allowed_image_file(upload.filename): raise ValueError("Bitte ein Bild als PNG, JPG, GIF oder WEBP hochladen.") if not upload_file_size_ok(upload, current_app.config["MAX_CONTENT_LENGTH"]): raise ValueError("Das Bild ist gerade zu groß. Ein etwas kleineres Foto hilft hier am besten.") return save_photo_with_variants(upload, current_app.config["UPLOAD_FOLDER"], current_filename=current_filename) def user_display_name(display_name: str | None, username: str | None) -> str: return display_name or username or "Haushalt" def describe_record(entry: dict) -> dict: owner_name = user_display_name(entry.get("owner_display_name"), entry.get("owner_username")) target_name = user_display_name(entry.get("target_display_name"), entry.get("target_username")) if entry.get("target_user_id") else None entry["owner_name"] = owner_name entry["target_name"] = target_name entry["energy_density"] = normalize_energy_density(entry.get("energy_density"), "neutral") entry["energy_density_label"] = ENERGY_DENSITY_LABELS.get(entry["energy_density"], ENERGY_DENSITY_LABELS["neutral"]) entry["base_type"] = normalize_base_type(entry.get("base_type"), "neutral") entry["base_type_label"] = BUILDER_LABELS.get(entry["base_type"], BUILDER_LABELS["neutral"]) entry["flavor_profile"] = normalize_food_flavor(entry.get("flavor_profile"), "neutral") entry["flavor_profile_label"] = FOOD_FLAVOR_LABELS.get(entry["flavor_profile"], FOOD_FLAVOR_LABELS["neutral"]) entry["suggestion_role"] = normalize_food_role(entry.get("suggestion_role"), "base") entry["suggestion_role_label"] = FOOD_ROLE_LABELS.get(entry["suggestion_role"], FOOD_ROLE_LABELS["base"]) entry["suggestion_priority"] = normalize_suggestion_priority(entry.get("suggestion_priority"), "normal") entry["suggestion_priority_label"] = SUGGESTION_PRIORITY_LABELS.get( entry["suggestion_priority"], SUGGESTION_PRIORITY_LABELS["normal"], ) entry["can_be_meal_core"] = bool(entry.get("can_be_meal_core")) entry["meal_type"] = normalize_meal_type( entry.get("meal_type"), DAYPART_SLUG_TO_MEAL_TYPE.get(entry.get("daypart_slug"), "snack"), ) entry["meal_type_label"] = MEAL_TYPE_LABELS.get(entry["meal_type"], MEAL_TYPE_LABELS["snack"]) entry["meal_tags"] = decode_tag_list(entry.get("meal_tags")) entry["meal_tag_labels"] = [MEAL_STYLE_LABELS.get(tag, tag) for tag in entry["meal_tags"]] entry["is_personal"] = entry.get("visibility") == "personal" entry["is_shared"] = entry.get("visibility") == "shared" entry["is_mine"] = entry.get("owner_user_id") == g.user["id"] entry["visibility_label"] = VISIBILITY_LABELS.get(entry.get("visibility"), "Gemeinsam") entry["visibility_description"] = VISIBILITY_DESCRIPTIONS.get(entry.get("visibility"), "") entry["owner_label"] = "Von mir" if entry["is_mine"] else f"Von {owner_name}" if target_name: entry["for_label"] = f"Für {target_name}" elif entry["is_personal"]: 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 def describe_records(rows) -> list[dict]: return [describe_record(dict(row)) for row in rows] def describe_template_record(entry: dict) -> dict: owner_name = user_display_name(entry.get("owner_display_name"), entry.get("owner_username")) entry["owner_name"] = owner_name entry["is_mine"] = entry.get("owner_user_id") == g.user["id"] entry["visibility_label"] = VISIBILITY_LABELS.get(entry.get("visibility"), "Gemeinsam") entry["owner_label"] = "Von mir" if entry["is_mine"] else f"Von {owner_name}" entry["can_edit"] = entry.get("visibility") == "shared" or entry["is_mine"] or g.user["role"] == "admin" return entry def ensure_can_edit(entry: dict, error_message: str = "Diesen Eintrag kannst du gerade nicht bearbeiten.") -> None: if not (entry.get("can_edit") or g.user["role"] == "admin"): raise PermissionError(error_message) def get_item(item_id: int) -> dict: item = get_db().execute( f""" SELECT items.*, owner.display_name AS owner_display_name, owner.username AS owner_username, target.display_name AS target_display_name, target.username AS target_username, EXISTS( SELECT 1 FROM shopping_entries WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0 ) AS is_on_shopping_list 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.id = ? AND {visible_clause('items')} """, [item_id, *visible_params()], ).fetchone() if item is None: raise ValueError("Der Eintrag wurde nicht gefunden.") return describe_record(dict(item)) def get_item_daypart_ids(item_id: int) -> list[int]: rows = get_db().execute( "SELECT daypart_id FROM item_dayparts WHERE item_id = ?", (item_id,), ).fetchall() return [int(row["daypart_id"]) for row in rows] def get_meal_component_ids(meal_id: int) -> list[int]: rows = get_db().execute( """ SELECT meal_components.food_item_id FROM meal_components JOIN items ON items.id = meal_components.food_item_id WHERE meal_components.meal_item_id = ? AND items.household_id = ? AND (items.visibility = 'shared' OR items.owner_user_id = ?) """, (meal_id, current_household_id(), g.user["id"]), ).fetchall() return [int(row["food_item_id"]) for row in rows] def attach_dayparts(items: list[dict]) -> list[dict]: if not items: return [] item_ids = [item["id"] for item in items] placeholders = ",".join("?" for _ in item_ids) rows = get_db().execute( f""" SELECT item_dayparts.item_id, dayparts.id, dayparts.slug, dayparts.name FROM item_dayparts JOIN dayparts ON dayparts.id = item_dayparts.daypart_id WHERE item_dayparts.item_id IN ({placeholders}) ORDER BY dayparts.sort_order """, item_ids, ).fetchall() grouped = defaultdict(list) for row in rows: grouped[row["item_id"]].append( {"id": row["id"], "slug": row["slug"], "name": row["name"]} ) for item in items: item["dayparts_meta"] = grouped.get(item["id"], []) item["dayparts"] = [daypart["name"] for daypart in item["dayparts_meta"]] item["primary_daypart_id"] = item["dayparts_meta"][0]["id"] if item["dayparts_meta"] else None return items def attach_components(items: list[dict]) -> list[dict]: meal_ids = [item["id"] for item in items if item["kind"] == "meal"] if not meal_ids: for item in items: item["components"] = [] item["component_ids"] = [] return items placeholders = ",".join("?" for _ in meal_ids) rows = get_db().execute( f""" SELECT meal_components.meal_item_id, meal_components.food_item_id, items.name FROM meal_components JOIN items ON items.id = meal_components.food_item_id WHERE meal_components.meal_item_id IN ({placeholders}) AND items.household_id = ? AND (items.visibility = 'shared' OR items.owner_user_id = ?) ORDER BY LOWER(items.name) """, [*meal_ids, current_household_id(), g.user["id"]], ).fetchall() grouped_names: dict[int, list[str]] = defaultdict(list) grouped_ids: dict[int, list[int]] = defaultdict(list) for row in rows: grouped_names[row["meal_item_id"]].append(row["name"]) grouped_ids[row["meal_item_id"]].append(int(row["food_item_id"])) for item in items: item["components"] = grouped_names.get(item["id"], []) item["component_ids"] = grouped_ids.get(item["id"], []) return items def attach_builder_keys(items: list[dict]) -> list[dict]: if not items: return [] meal_ids = [item["id"] for item in items if item["kind"] == "meal"] meal_builder_map: dict[int, set[str]] = defaultdict(set) if meal_ids: placeholders = ",".join("?" for _ in meal_ids) rows = get_db().execute( f""" SELECT meal_components.meal_item_id, component.base_type FROM meal_components JOIN items AS component ON component.id = meal_components.food_item_id WHERE meal_components.meal_item_id IN ({placeholders}) """, meal_ids, ).fetchall() for row in rows: builder_key = normalize_base_type(row["base_type"], "neutral") meal_builder_map[int(row["meal_item_id"])].add(builder_key) for item in items: builder_keys: list[str] if item["kind"] == "meal": builder_keys = sorted(meal_builder_map.get(item["id"], set())) if not builder_keys: builder_keys = [normalize_base_type(item.get("base_type"), "neutral")] else: builder_keys = [normalize_base_type(item.get("base_type"), "neutral")] item["builder_keys"] = builder_keys item["builder_labels"] = [BUILDER_LABELS.get(key, BUILDER_LABELS["neutral"]) for key in builder_keys] item["primary_builder_key"] = builder_keys[0] if builder_keys else "neutral" return items def decorate_items(rows) -> list[dict]: return attach_builder_keys(attach_components(attach_dayparts(describe_records(rows)))) def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]: if not item_ids: return {} placeholders = ",".join("?" for _ in item_ids) rows = get_db().execute( f""" SELECT id, kind, base_type FROM items WHERE id IN ({placeholders}) """, item_ids, ).fetchall() builder_map: dict[int, set[str]] = {int(row["id"]): {normalize_base_type(row["base_type"], "neutral")} for row in rows} meal_ids = [int(row["id"]) for row in rows if row["kind"] == "meal"] if meal_ids: meal_placeholders = ",".join("?" for _ in meal_ids) component_rows = get_db().execute( f""" SELECT meal_components.meal_item_id, component.base_type FROM meal_components JOIN items AS component ON component.id = meal_components.food_item_id WHERE meal_components.meal_item_id IN ({meal_placeholders}) """, meal_ids, ).fetchall() for row in component_rows: builder_map.setdefault(int(row["meal_item_id"]), set()).add( normalize_base_type(row["base_type"], "neutral") ) return builder_map def suggestion_priority_score(priority: str) -> int: return { "prefer": 8, "normal": 3, "rare": -6, "never": -50, }.get(priority, 0) def is_animal_protein_item(item: dict) -> bool: normalized = (item.get("name") or "").strip().lower() return any( token in normalized for token in ("huhn", "hähn", "rind", "schwein", "speck", "salami", "wurst", "thunfisch", "lachs", "fisch", "garnelen", "shrimp", "sardinen") ) def protein_preference_score(item: dict, settings: dict) -> int: preference = normalize_protein_preference(settings.get("protein_preference"), "mixed") if not is_animal_protein_item(item): return 2 if preference in {"veg-friendly", "rare-animal", "plant-forward"} else 0 if preference == "mixed": return 0 if preference == "veg-friendly": return -4 if preference == "rare-animal": return -8 if preference == "plant-forward": return -14 return 0 def meaningful_component(item: dict) -> bool: role = normalize_food_role(item.get("suggestion_role"), "base") if role in {"topping", "cooking"}: return False return bool(item.get("can_be_meal_core")) or role in {"base", "main", "solo", "snack", "complement"} def food_supports_slot(food: dict, slot: dict) -> bool: if normalize_suggestion_priority(food.get("suggestion_priority"), "normal") == "never": return False if slot.get("core_only") and not bool(food.get("can_be_meal_core")): return False role = normalize_food_role(food.get("suggestion_role"), "base") if slot.get("roles") and role not in slot["roles"]: return False base_type = normalize_base_type(food.get("base_type"), "neutral") accepted = set(slot.get("base_types", set())) matches_base_type = ("fiber" in accepted and base_type in {"veg", "fruit"}) or base_type in accepted if not matches_base_type: return False accepted_flavors = set(slot.get("flavors", set())) if not accepted_flavors: return True return normalize_food_flavor(food.get("flavor_profile"), "neutral") in accepted_flavors def components_are_flavor_compatible(component_items: list[dict]) -> bool: flavors = { normalize_food_flavor(item.get("flavor_profile"), "neutral") for item in component_items if meaningful_component(item) } return not ({"sweet", "savory"} <= flavors) def score_suggestion_components(component_items: list[dict], daypart_slug: str, settings: dict) -> int: if not components_are_flavor_compatible(component_items): return -999 meaningful_items = [item for item in component_items if meaningful_component(item)] builder_keys = {key for item in meaningful_items for key in item.get("builder_keys", ["neutral"])} energy_values = [normalize_energy_density(item.get("energy_density"), "neutral") for item in component_items] score = sum(suggestion_priority_score(normalize_suggestion_priority(item.get("suggestion_priority"), "normal")) for item in component_items) score += sum(protein_preference_score(item, settings) for item in component_items) score += sum(2 for item in component_items if bool(item.get("can_be_meal_core"))) style = settings.get("suggestion_style", "balanced") if style == "fitness": score += 9 if "protein" in builder_keys else 0 score += 4 if daypart_slug in {"lunch", "dinner"} and "veg" in builder_keys else 0 score += 2 if "carb" in builder_keys else 0 elif style == "protein": score += 8 if "protein" in builder_keys else 0 score += 3 if daypart_slug in {"lunch", "dinner"} and "veg" in builder_keys else 0 elif style == "easy": score += 5 if any(normalize_food_role(item.get("suggestion_role"), "base") in {"solo", "base"} for item in component_items) else 0 score += 4 if len(component_items) <= 3 else -2 elif style == "snack": score += 5 if daypart_slug in {"morning-snack", "afternoon-snack", "late-snack"} else 0 score += 3 if any(item.get("base_type") in {"fruit", "dairy", "nuts", "seeds"} for item in component_items) else 0 else: if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}: score += 5 if "carb" in builder_keys else 0 score += 4 if builder_keys & {"dairy", "fruit", "nuts", "seeds"} else 0 else: score += 5 if "protein" in builder_keys else 0 score += 4 if "carb" in builder_keys else 0 score += 4 if builder_keys & {"veg", "fruit"} else 0 energy_preference = settings.get("energy_preference", "neutral") if style == "fitness": score += energy_values.count("low") * 4 score -= energy_values.count("high") * 2 elif energy_preference == "high": score += energy_values.count("high") * 3 score -= energy_values.count("low") elif energy_preference == "low": score += energy_values.count("low") * 3 score -= energy_values.count("high") else: score += energy_values.count("neutral") return score def fetch_items( *, 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, ): conditions = [visible_clause("items")] params = visible_params() if kind: conditions.append("items.kind = ?") params.append(kind) if 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.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()}%") if daypart_id: conditions.append( """ EXISTS ( SELECT 1 FROM item_dayparts WHERE item_dayparts.item_id = items.id AND item_dayparts.daypart_id = ? ) """ ) params.append(daypart_id) if visibility: conditions.append("items.visibility = ?") params.append(visibility) rows = get_db().execute( f""" SELECT items.*, owner.display_name AS owner_display_name, owner.username AS owner_username, target.display_name AS target_display_name, target.username AS target_username, EXISTS( SELECT 1 FROM shopping_entries WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0 ) AS is_on_shopping_list 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 {' AND '.join(conditions)} ORDER BY 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) """, params, ).fetchall() return decorate_items(rows) def fetch_food_options(query: str | None = None): 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: key = "archived" if item.get("is_archived") else item.get("availability_state", "idea") grouped[key].append(item) result = [] for state in ("home", "idea", "archived"): entries = grouped.get(state, []) if entries: result.append({"state": state, "title": AVAILABILITY_LABELS[state], "items": entries}) return result def extract_item_form_data(kind: str, existing: dict | None = None) -> dict: form_data = existing or {} daypart_ids = [int(value) for value in request.form.getlist("daypart_ids") if value.isdigit()] meal_type_default = form_data.get("meal_type") or meal_type_for_daypart(daypart_ids[0] if daypart_ids else None) meal_type = normalize_meal_type(request.form.get("meal_type"), meal_type_default) if kind == "meal": daypart_ids = daypart_ids_for_meal_type(meal_type) component_ids = list(dict.fromkeys(int(value) for value in request.form.getlist("component_ids") if value.isdigit())) form_data.update( { "name": request.form.get("name", "").strip(), "category": request.form.get("category", "").strip(), "base_type": normalize_base_type(request.form.get("base_type"), form_data.get("base_type", "neutral")), "flavor_profile": normalize_food_flavor(request.form.get("flavor_profile"), form_data.get("flavor_profile", "neutral")), "suggestion_role": normalize_food_role(request.form.get("suggestion_role"), form_data.get("suggestion_role", "base")), "suggestion_priority": normalize_suggestion_priority( request.form.get("suggestion_priority"), form_data.get("suggestion_priority", "normal"), ), "can_be_meal_core": request.form.get("can_be_meal_core", "0") == "1", "meal_type": meal_type, "meal_tags": normalize_meal_tags(request.form.getlist("meal_tags")), "energy_density": normalize_energy_density(request.form.get("energy_density"), form_data.get("energy_density", "neutral")), "note": request.form.get("note", "").strip(), "visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")), "target_user_id": normalize_target_user_id(request.form.get("target_user_id")), "target_user_raw": request.form.get("target_user_id", TARGET_USER_OPTIONS_DEFAULT), "food_search": request.form.get("food_search", "").strip(), "daypart_ids": daypart_ids, "component_ids": component_ids, "quick_food_name": request.form.get("quick_food_name", "").strip(), "quick_food_category": request.form.get("quick_food_category", "").strip(), "quick_food_base_type": normalize_base_type( request.form.get("quick_food_base_type"), form_data.get("quick_food_base_type", "neutral"), ), "quick_food_flavor_profile": normalize_food_flavor( request.form.get("quick_food_flavor_profile"), form_data.get("quick_food_flavor_profile", "neutral"), ), "quick_food_role": normalize_food_role( request.form.get("quick_food_role"), form_data.get("quick_food_role", "base"), ), "quick_food_priority": normalize_suggestion_priority( request.form.get("quick_food_priority"), form_data.get("quick_food_priority", "normal"), ), "quick_food_can_be_meal_core": request.form.get("quick_food_can_be_meal_core", "0") == "1", "quick_food_energy_density": normalize_energy_density(request.form.get("quick_food_energy_density"), form_data.get("quick_food_energy_density", "neutral")), "quick_food_note": request.form.get("quick_food_note", "").strip(), } ) return form_data def create_quick_food_from_form(form_data: dict) -> int: 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, energy_density, note, created_by, updated_by ) VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( current_household_id(), g.user["id"], form_data["target_user_id"], form_data["visibility"], form_data["quick_food_name"], form_data["quick_food_category"], form_data["quick_food_base_type"], form_data["quick_food_flavor_profile"], form_data["quick_food_role"], form_data["quick_food_priority"], 1 if form_data["quick_food_can_be_meal_core"] else 0, form_data["quick_food_energy_density"], form_data["quick_food_note"], g.user["id"], g.user["id"], ), ) food_id = int(cursor.lastrowid) sync_item_dayparts(food_id, form_data["daypart_ids"]) get_db().commit() return food_id def shopping_activation_date_for(needed_date: date) -> date: settings = get_household_settings() shopping_weekday = int(settings["shopping_weekday"]) prep_days = int(settings["shopping_prep_days"]) days_back = (needed_date.weekday() - shopping_weekday) % 7 shopping_date = needed_date - timedelta(days=days_back) return shopping_date - timedelta(days=prep_days) def should_activate_shopping_need(needed_date: date, today: date | None = None) -> bool: return (today or date.today()) >= shopping_activation_date_for(needed_date) 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, user_id: int, visibility: str, needed_for_date: str, needed_for_daypart_id: int | None, source_item_id: int | None = None, ) -> bool: activation_date = shopping_activation_date_for(parse_plan_date(needed_for_date)) existing = get_db().execute( """ SELECT id FROM shopping_needs WHERE household_id = ? AND item_id = ? AND COALESCE(source_item_id, 0) = COALESCE(?, 0) AND needed_for_date = ? AND COALESCE(needed_for_daypart_id, 0) = COALESCE(?, 0) AND visibility = ? AND is_activated = 0 LIMIT 1 """, ( current_household_id(), item_id, source_item_id, needed_for_date, needed_for_daypart_id, visibility, ), ).fetchone() if existing: get_db().execute( """ UPDATE shopping_needs SET activation_date = ?, owner_user_id = ? WHERE id = ? """, (activation_date.isoformat(), user_id, existing["id"]), ) else: get_db().execute( """ INSERT INTO shopping_needs ( household_id, owner_user_id, visibility, item_id, source_item_id, needed_for_date, needed_for_daypart_id, activation_date, created_by ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( current_household_id(), user_id, visibility, item_id, source_item_id, needed_for_date, needed_for_daypart_id, activation_date.isoformat(), g.user["id"], ), ) get_db().commit() return True def add_to_shopping_list( item_id: int, user_id: int, *, visibility_override: str | None = None, needed_for_date: str | None = None, needed_for_daypart_id: int | None = None, shopping_note: str | None = None, ) -> bool: item = get_item(item_id) normalized_note = normalize_shopping_note(shopping_note) existing = get_db().execute( """ SELECT id FROM shopping_entries WHERE item_id = ? AND shopping_note = ? AND is_checked = 0 """, (item_id, normalized_note), ).fetchone() if existing: return False visibility = normalize_visibility(visibility_override, item["visibility"]) owner_user_id = user_id if visibility == "personal" else item["owner_user_id"] get_db().execute( """ INSERT INTO shopping_entries ( household_id, owner_user_id, visibility, item_id, shopping_note, added_by, needed_for_date, needed_for_daypart_id ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( current_household_id(), owner_user_id, visibility, item_id, normalized_note, user_id, needed_for_date, needed_for_daypart_id, ), ) get_db().commit() return True def fetch_meal_missing_components(meal_id: int) -> list[dict]: rows = get_db().execute( """ SELECT items.*, owner.display_name AS owner_display_name, owner.username AS owner_username, target.display_name AS target_display_name, target.username AS target_username, 0 AS is_on_shopping_list FROM meal_components JOIN items ON items.id = meal_components.food_item_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 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) """, (meal_id, current_household_id(), g.user["id"]), ).fetchall() return attach_builder_keys(attach_dayparts(describe_records(rows))) def ensure_item_or_missing_components_are_shopped( item_id: int, user_id: int, visibility: str, *, needed_for_date: str | None = None, needed_for_daypart_id: int | None = None, source_item_id: int | None = None, shopping_note: str | None = None, ) -> dict: item = get_item(item_id) if item["kind"] == "meal": missing_components = fetch_meal_missing_components(item_id) if missing_components: added_names = [] scheduled_names = [] for component in missing_components: if needed_for_date and not should_activate_shopping_need(parse_plan_date(needed_for_date)): schedule_shopping_need( item_id=component["id"], user_id=user_id, visibility=visibility, needed_for_date=needed_for_date, needed_for_daypart_id=needed_for_daypart_id, source_item_id=source_item_id or item_id, ) scheduled_names.append(component["name"]) else: added = add_to_shopping_list( component["id"], user_id, visibility_override=visibility, needed_for_date=needed_for_date, needed_for_daypart_id=needed_for_daypart_id, ) if added: added_names.append(component["name"]) return { "added": bool(added_names), "count": len(added_names), "names": added_names, "scheduled_count": len(scheduled_names), "scheduled_names": scheduled_names, "used_components": True, } return { "added": False, "count": 0, "names": [], "scheduled_count": 0, "scheduled_names": [], "used_components": True, } if needed_for_date and not should_activate_shopping_need(parse_plan_date(needed_for_date)): schedule_shopping_need( item_id=item_id, user_id=user_id, visibility=visibility, needed_for_date=needed_for_date, needed_for_daypart_id=needed_for_daypart_id, source_item_id=source_item_id, ) return { "added": False, "count": 0, "names": [], "scheduled_count": 1, "scheduled_names": [item["name"]], "used_components": False, } added = add_to_shopping_list( item_id, user_id, visibility_override=visibility, needed_for_date=needed_for_date, needed_for_daypart_id=needed_for_daypart_id, shopping_note=shopping_note, ) return { "added": added, "count": 1 if added else 0, "names": [item["name"]] if added else [], "scheduled_count": 0, "scheduled_names": [], "used_components": False, } def sync_item_dayparts(item_id: int, daypart_ids: list[int]) -> None: get_db().execute("DELETE FROM item_dayparts WHERE item_id = ?", (item_id,)) for daypart_id in daypart_ids: get_db().execute( "INSERT INTO item_dayparts (item_id, daypart_id) VALUES (?, ?)", (item_id, daypart_id), ) def sync_meal_components(meal_id: int, food_ids: list[int]) -> None: get_db().execute("DELETE FROM meal_components WHERE meal_item_id = ?", (meal_id,)) visible_foods = { row["id"] for row in get_db().execute( f""" SELECT items.id FROM items WHERE items.kind = 'food' AND {visible_clause('items')} """, visible_params(), ).fetchall() } for food_id in food_ids: if food_id in visible_foods: get_db().execute( "INSERT INTO meal_components (meal_item_id, food_item_id) VALUES (?, ?)", (meal_id, food_id), ) def fetch_items_by_ids(item_ids: list[int]) -> list[dict]: normalized_ids = list(dict.fromkeys(int(item_id) for item_id in item_ids if int(item_id) > 0)) if not normalized_ids: return [] placeholders = ", ".join("?" for _ in normalized_ids) rows = get_db().execute( f""" SELECT items.*, owner.display_name AS owner_display_name, owner.username AS owner_username, target.display_name AS target_display_name, target.username AS target_username, EXISTS( SELECT 1 FROM shopping_entries WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0 ) AS is_on_shopping_list 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.id IN ({placeholders}) AND {visible_clause('items')} """, [*normalized_ids, *visible_params()], ).fetchall() items_by_id = {item["id"]: item for item in decorate_items(rows)} return [items_by_id[item_id] for item_id in normalized_ids if item_id in items_by_id] 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( f""" SELECT items.*, owner.display_name AS owner_display_name, owner.username AS owner_username, target.display_name AS target_display_name, target.username AS target_username, EXISTS( SELECT 1 FROM shopping_entries WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0 ) AS is_on_shopping_list 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 IN ('food', 'shopping') AND items.is_archived = 0 AND LOWER(items.name) = ? AND {visible_clause('items')} ORDER BY CASE items.kind WHEN 'food' THEN 0 ELSE 1 END, LOWER(items.name), items.id LIMIT 1 """, [normalized_name, *visible_params()], ).fetchone() if row is None: return 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""" 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, target.username AS target_username, dayparts.name AS needed_daypart_name FROM shopping_entries JOIN items ON items.id = shopping_entries.item_id LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id LEFT JOIN users AS target ON target.id = items.target_user_id LEFT JOIN dayparts ON dayparts.id = shopping_entries.needed_for_daypart_id WHERE shopping_entries.is_checked = 0 AND {visible_clause('shopping_entries')} ORDER BY CASE shopping_entries.visibility WHEN 'shared' THEN 0 ELSE 1 END, shopping_entries.added_at DESC """, visible_params(), ).fetchall() entries = describe_records(rows) for entry in entries: if entry.get("needed_for_date"): try: parsed = datetime.strptime(entry["needed_for_date"], "%Y-%m-%d").date() entry["needed_for_label"] = parsed.strftime("%d.%m.%Y") except ValueError: entry["needed_for_label"] = entry["needed_for_date"] else: entry["needed_for_label"] = None return entries def activate_due_shopping_needs(today: date | None = None) -> int: today = today or date.today() rows = get_db().execute( """ SELECT shopping_needs.*, items.availability_state FROM shopping_needs JOIN items ON items.id = shopping_needs.item_id 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()), ).fetchall() activated = 0 for row in rows: if row["availability_state"] != "home": was_added = add_to_shopping_list( int(row["item_id"]), int(row["owner_user_id"] or g.user["id"]), visibility_override=row["visibility"], needed_for_date=row["needed_for_date"], needed_for_daypart_id=row["needed_for_daypart_id"], ) activated += 1 if was_added else 0 get_db().execute( "UPDATE shopping_needs SET is_activated = 1, activated_at = CURRENT_TIMESTAMP WHERE id = ?", (row["id"],), ) if rows: get_db().commit() return activated def fetch_upcoming_shopping_needs(limit: int | None = None) -> list[dict]: query = f""" SELECT shopping_needs.*, items.name AS item_name, 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, target.username AS target_username, dayparts.name AS needed_daypart_name FROM shopping_needs JOIN items ON items.id = shopping_needs.item_id LEFT JOIN users AS owner ON owner.id = shopping_needs.owner_user_id LEFT JOIN users AS target ON target.id = items.target_user_id LEFT JOIN dayparts ON dayparts.id = shopping_needs.needed_for_daypart_id 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) """ params: list[object] = [current_household_id(), g.user["id"]] if limit: query += " LIMIT ?" params.append(limit) rows = get_db().execute(query, params).fetchall() entries = describe_records(rows) for entry in entries: try: entry["needed_for_label"] = datetime.strptime(entry["needed_for_date"], "%Y-%m-%d").date().strftime("%d.%m.%Y") entry["activation_label"] = datetime.strptime(entry["activation_date"], "%Y-%m-%d").date().strftime("%d.%m.%Y") except ValueError: entry["needed_for_label"] = entry["needed_for_date"] entry["activation_label"] = entry["activation_date"] return entries def fetch_plan_entries_for_range(start_date: date, end_date: date): rows = get_db().execute( f""" SELECT plan_entries.*, items.name AS item_name, 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, owner.display_name AS owner_display_name, owner.username AS owner_username, target.display_name AS target_display_name, target.username AS target_username FROM plan_entries JOIN items ON items.id = plan_entries.item_id JOIN dayparts ON dayparts.id = plan_entries.daypart_id LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id LEFT JOIN users AS target ON target.id = items.target_user_id WHERE plan_date BETWEEN ? AND ? AND {visible_clause('plan_entries')} ORDER BY plan_date, dayparts.sort_order, items.name """, [start_date.isoformat(), end_date.isoformat(), *visible_params()], ).fetchall() grouped = defaultdict(list) for row in describe_records(rows): grouped[(row["plan_date"], row["daypart_id"])].append(row) return grouped def fetch_day_plan_entries(selected_date: date): return fetch_plan_entries_for_range(selected_date, selected_date) def fetch_recent_plan_items(daypart_id: int, limit: int = 6): rows = get_db().execute( f""" SELECT DISTINCT items.id, items.household_id, items.owner_user_id, items.target_user_id, items.visibility, items.name, items.kind, items.category, 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, target.username AS target_username FROM plan_entries JOIN items ON items.id = plan_entries.item_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 WHERE plan_entries.daypart_id = ? AND {visible_clause('items')} ORDER BY plan_entries.created_at DESC LIMIT ? """, [daypart_id, *visible_params(), limit * 3], ).fetchall() return decorate_items(rows) def fetch_plan_candidates(daypart_id: int, query: str | None = None): params = [daypart_id, *visible_params()] conditions = [visible_clause("items"), "items.is_archived = 0"] if query: conditions.append("LOWER(items.name) LIKE ?") params.append(f"%{query.lower()}%") rows = get_db().execute( f""" SELECT items.*, owner.display_name AS owner_display_name, owner.username AS owner_username, target.display_name AS target_display_name, target.username AS target_username, EXISTS( SELECT 1 FROM item_dayparts WHERE item_dayparts.item_id = items.id AND item_dayparts.daypart_id = ? ) AS matches_daypart 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 {' AND '.join(conditions)} ORDER BY CASE items.availability_state WHEN 'home' THEN 0 WHEN 'idea' THEN 1 ELSE 2 END, matches_daypart DESC, CASE items.visibility WHEN 'shared' THEN 0 ELSE 1 END, LOWER(items.name) """, params, ).fetchall() items = decorate_items(rows) return [ item for item in items if item["kind"] != "meal" or meal_matches_daypart(item, daypart_id) ] def fetch_home_food_ids() -> set[int]: rows = get_db().execute( f""" SELECT id FROM items WHERE kind = 'food' AND availability_state = 'home' AND is_archived = 0 AND {visible_clause('items')} """, visible_params(), ).fetchall() return {int(row["id"]) for row in rows} def get_daypart_by_id(daypart_id: int): for daypart in get_dayparts(): if int(daypart["id"]) == int(daypart_id): return daypart return None def meal_type_for_daypart(daypart_id: int | None) -> str: daypart = get_daypart_by_id(daypart_id) if daypart_id else None if not daypart: return "snack" return DAYPART_SLUG_TO_MEAL_TYPE.get(daypart["slug"], "snack") def daypart_ids_for_meal_type(meal_type: str | None) -> list[int]: normalized_type = normalize_meal_type(meal_type, "snack") return [ int(daypart["id"]) for daypart in get_dayparts() if DAYPART_SLUG_TO_MEAL_TYPE.get(daypart["slug"], "snack") == normalized_type ] def meal_tags_for_generated_meal(daypart_id: int, foods: list[dict]) -> list[str]: daypart = get_daypart_by_id(daypart_id) slug = daypart["slug"] if daypart else "" tags: list[str] = [] if slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}: if any(item.get("base_type") in {"fruit", "dairy"} for item in foods): tags.append("sweet") if any(item.get("base_type") == "carb" for item in foods): tags.append("simple") else: if any(item.get("base_type") in {"protein", "veg"} for item in foods): tags.append("savory") if any(item.get("suggestion_role") == "solo" for item in foods): tags.append("quick") if any(item.get("base_type") in {"dairy", "fruit"} for item in foods): tags.append("cold") if any(item.get("base_type") in {"protein", "veg"} for item in foods) and slug in {"lunch", "dinner"}: tags.append("warm") normalized: list[str] = [] for tag in tags: if tag not in normalized: normalized.append(tag) return normalized def format_item_names(items: list[dict], limit: int = 3) -> str: return ", ".join(item["name"] for item in items[:limit]) def item_matches_daypart(item: dict, daypart_id: int | None) -> bool: if daypart_id is None: return True dayparts_meta = item.get("dayparts_meta") or [] if not dayparts_meta: return True return any(int(daypart["id"]) == int(daypart_id) for daypart in dayparts_meta) def meal_matches_daypart(item: dict, daypart_id: int | None) -> bool: if item.get("kind") != "meal" or daypart_id is None: return True dayparts_meta = item.get("dayparts_meta") or [] if dayparts_meta: return any(int(daypart["id"]) == int(daypart_id) for daypart in dayparts_meta) raw_meal_type = (item.get("meal_type") or "").strip() if not raw_meal_type: return False expected_meal_type = meal_type_for_daypart(daypart_id) return normalize_meal_type(raw_meal_type, "snack") == expected_meal_type def normalized_component_signature(component_ids: list[int]) -> tuple[int, ...]: return tuple(sorted({int(component_id) for component_id in component_ids})) def generated_suggestion_key(component_ids: list[int]) -> str: signature = normalized_component_signature(component_ids) return "generated:" + "-".join(str(component_id) for component_id in signature) def fetch_hidden_generated_suggestion_keys() -> set[str]: rows = get_db().execute( """ SELECT suggestion_key FROM hidden_generated_suggestions WHERE user_id = ? """, (g.user["id"],), ).fetchall() return {row["suggestion_key"] for row in rows} def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str: names = [item["name"] for item in combo] if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} and len(names) >= 2: return f"{names[0]} mit {', '.join(names[1:])}" if len(names) >= 2: return f"{names[0]} mit {', '.join(names[1:])}" return names[0] def meal_pattern_definitions(daypart_slug: str) -> list[dict]: if daypart_slug == "breakfast": return [ { "reason": "Passt gut zu Frühstück", "slots": [ {"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True, "flavors": {"sweet", "neutral"}}, {"base_types": {"dairy", "protein"}, "roles": {"base", "main", "complement", "solo", "snack"}, "core_only": False, "flavors": {"sweet", "neutral"}}, {"base_types": {"fruit"}, "roles": {"complement", "topping", "snack", "base"}, "core_only": False, "flavors": {"sweet", "neutral"}}, ], }, { "reason": "Passt gut für Frühstück", "slots": [ {"base_types": {"dairy"}, "roles": {"base", "main", "solo", "snack"}, "core_only": True, "flavors": {"sweet", "neutral"}}, {"base_types": {"carb"}, "roles": {"base", "main", "complement", "solo"}, "core_only": False, "flavors": {"sweet", "neutral"}}, {"base_types": {"nuts", "seeds", "fruit"}, "roles": {"topping", "complement", "snack"}, "core_only": False, "flavors": {"sweet", "neutral"}}, ], }, ] if daypart_slug in {"morning-snack", "afternoon-snack", "late-snack"}: return [ { "reason": "Passt gut zu einem kleinen Snack", "slots": [ {"base_types": {"dairy"}, "roles": {"base", "solo", "snack"}, "core_only": True, "flavors": {"sweet", "neutral"}}, {"base_types": {"fruit"}, "roles": {"complement", "snack", "topping"}, "core_only": False, "flavors": {"sweet", "neutral"}}, ], }, { "reason": "Zuhause schnell kombinierbar", "slots": [ {"base_types": {"fruit"}, "roles": {"base", "snack", "complement"}, "core_only": True, "flavors": {"sweet", "neutral"}}, {"base_types": {"nuts", "seeds"}, "roles": {"topping", "snack", "complement"}, "core_only": False, "flavors": {"sweet", "neutral"}}, ], }, { "reason": "Passt gut zu einem kleinen Snack", "slots": [ {"base_types": {"carb"}, "roles": {"solo", "base", "snack"}, "core_only": True, "flavors": {"sweet", "neutral"}}, {"base_types": {"protein", "dairy"}, "roles": {"complement", "snack", "base"}, "core_only": False, "flavors": {"sweet", "neutral"}}, ], }, ] return [ { "reason": "Zuhause als vollständige Mahlzeit möglich", "slots": [ {"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True, "flavors": {"savory", "neutral"}}, {"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True, "flavors": {"savory", "neutral"}}, {"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False, "flavors": {"savory", "neutral"}}, ], }, { "reason": "Lässt sich gut ergänzen", "slots": [ {"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True, "flavors": {"savory", "neutral"}}, {"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False, "flavors": {"savory", "neutral"}}, {"base_types": {"carb"}, "roles": {"base", "complement", "solo"}, "core_only": False, "flavors": {"savory", "neutral"}}, ], }, { "reason": "Schnell und alltagstauglich", "slots": [ {"base_types": {"carb", "protein"}, "roles": {"solo"}, "core_only": True, "flavors": {"savory", "neutral"}}, {"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False, "flavors": {"savory", "neutral"}}, ], }, ] def score_food_for_pattern(food: dict, settings: dict) -> int: score = suggestion_priority_score(normalize_suggestion_priority(food.get("suggestion_priority"), "normal")) score += protein_preference_score(food, settings) if bool(food.get("can_be_meal_core")): score += 3 role = normalize_food_role(food.get("suggestion_role"), "base") if role == "main": score += 3 elif role == "base": score += 2 elif role == "solo": score += 4 return score def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]: settings = get_user_settings() target_patterns = meal_pattern_definitions(daypart_slug) suggestions: list[dict] = [] seen_signatures: set[tuple[int, ...]] = set() for pattern in target_patterns: slot_candidates = [] for slot in pattern["slots"]: matches = [food for food in home_foods if food_supports_slot(food, slot)] matches = sorted(matches, key=lambda food: (-score_food_for_pattern(food, settings), food["name"].lower())) if not matches: slot_candidates = [] break slot_candidates.append(matches[:6]) if not slot_candidates: continue for combo in product(*slot_candidates): signature = normalized_component_signature([item["id"] for item in combo]) if len(signature) != len(combo) or signature in seen_signatures: continue seen_signatures.add(signature) combo_items = list(combo) if not components_are_flavor_compatible(combo_items): continue suggestions.append( { "title": build_generated_meal_name(combo_items, daypart_slug), "reason": pattern["reason"], "component_ids": [item["id"] for item in combo_items], "existing_item_id": None, "visibility": "shared", "daypart_id": None, "missing_component_ids": [], "missing_components": [], "needs_shopping": False, "is_generated": True, "suggestion_key": generated_suggestion_key([item["id"] for item in combo_items]), "score": score_suggestion_components(combo_items, daypart_slug=daypart_slug, settings=settings), } ) if len(suggestions) >= limit * 3: break if len(suggestions) >= limit * 3: break return suggestions def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]: settings = get_user_settings() daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "") hidden_keys = fetch_hidden_generated_suggestion_keys() home_foods = [ item for item in fetch_items(kind="food", availability="home") if item_matches_daypart(item, daypart_id) ] home_food_ids = {item["id"] for item in home_foods} home_food_map = {int(item["id"]): item for item in home_foods} visible_foods = [ item for item in fetch_items(kind="food", include_archived=False) if item_matches_daypart(item, daypart_id) ] visible_food_map = {int(item["id"]): item for item in visible_foods} suggestions: list[dict] = [] seen_signatures: set[tuple[int, ...]] = set() meals = [item for item in fetch_items(kind="meal") if item_matches_daypart(item, daypart_id)] for meal in meals: if not meal["component_ids"]: continue component_ids = [int(component_id) for component_id in meal["component_ids"]] if not all(component_id in visible_food_map for component_id in component_ids): continue signature = normalized_component_signature(component_ids) if signature in seen_signatures: continue component_items = [visible_food_map[component_id] for component_id in component_ids] if not components_are_flavor_compatible(component_items): continue available_items = [home_food_map[component_id] for component_id in component_ids if component_id in home_food_map] missing_items = [visible_food_map[component_id] for component_id in component_ids if component_id not in home_food_ids] if not available_items: continue if missing_items and len(missing_items) > 2: continue seen_signatures.add(signature) if missing_items: missing_names = [item["name"] for item in missing_items] suggestions.append( { "title": meal["name"], "reason": f"Es fehlt noch: {', '.join(missing_names)}", "component_ids": component_ids, "existing_item_id": meal["id"], "visibility": meal["visibility"], "daypart_id": daypart_id or meal.get("primary_daypart_id"), "missing_component_ids": [item["id"] for item in missing_items], "missing_components": missing_names, "needs_shopping": True, "is_generated": False, "suggestion_key": None, "score": score_suggestion_components(available_items, daypart_slug=daypart_slug, settings=settings) + 18 - (len(missing_items) * 4), } ) else: suggestions.append( { "title": meal["name"], "reason": "Zuhause vorhanden", "component_ids": component_ids, "existing_item_id": meal["id"], "visibility": meal["visibility"], "daypart_id": daypart_id or meal.get("primary_daypart_id"), "missing_component_ids": [], "missing_components": [], "needs_shopping": False, "is_generated": False, "suggestion_key": None, "score": score_suggestion_components(component_items, daypart_slug=daypart_slug, settings=settings) + 40, } ) for suggestion in build_dynamic_meal_suggestions(home_foods, daypart_slug, limit=limit * 2): signature = normalized_component_signature(suggestion["component_ids"]) if signature in seen_signatures or suggestion["suggestion_key"] in hidden_keys: continue seen_signatures.add(signature) component_items = [home_food_map[component_id] for component_id in suggestion["component_ids"] if component_id in home_food_map] suggestion["score"] = score_suggestion_components(component_items, daypart_slug=daypart_slug, settings=settings) suggestions.append(suggestion) deduped: list[dict] = [] seen = set() ranked_suggestions = sorted( suggestions, key=lambda suggestion: ( -int(suggestion.get("score", 0)), 0 if suggestion.get("existing_item_id") else 1, suggestion["title"].lower(), ), ) for suggestion in ranked_suggestions: if suggestion["title"] in seen: continue seen.add(suggestion["title"]) deduped.append(suggestion) if len(deduped) >= limit: break return deduped def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | None: settings = get_user_settings() if not (settings.get("reminders_enabled") and settings.get("show_meal_balancing")): return None daypart = get_daypart_by_id(daypart_id) if not daypart or daypart["slug"] not in {"lunch", "dinner"}: return None builder_map = fetch_builder_keys_for_item_ids(item_ids) present = set() for keys in builder_map.values(): present.update(keys) has_fiber = bool(present & {"veg", "fruit"}) missing = [] if "protein" not in present: missing.append("protein") if "carb" not in present: missing.append("carb") if not has_fiber: missing.append("fiber") if not missing: return None first_missing = missing[0] home_matches = [ item for item in fetch_items(kind="food", availability="home", daypart_id=daypart_id) if ( (first_missing == "fiber" and bool(set(item.get("builder_keys", [])) & {"veg", "fruit"})) or first_missing in item.get("builder_keys", []) ) and meaningful_component(item) and normalize_suggestion_priority(item.get("suggestion_priority"), "normal") != "never" ] home_matches = sorted( home_matches, key=lambda item: -score_suggestion_components([item], daypart["slug"], settings), ) text_map = { "protein": "Dazu könnte noch eine Proteinquelle gut passen.", "carb": "Das lässt sich gut mit einer Kohlenhydratquelle ergänzen.", "fiber": "Dazu könnte noch etwas Gemüse oder Obst gut passen.", } return { "text": text_map.get(first_missing, "Dazu könnte noch etwas Kleines gut passen."), "items": home_matches[:3], } def build_daypart_suggestions(daypart_id: int) -> list[dict]: settings = get_user_settings() if not settings.get("suggest_home_for_today"): return [] suggestions = build_home_recipe_suggestions(daypart_id, limit=3) if suggestions: return suggestions archived_items = fetch_items(availability="archived", include_archived=True, daypart_id=daypart_id) return [ { "title": item["name"], "reason": "Für später vormerken", "component_ids": [], "existing_item_id": item["id"] if item["kind"] == "meal" else None, "visibility": item["visibility"], "daypart_id": daypart_id, "missing_component_ids": [], "missing_components": [], "needs_shopping": False, "is_generated": False, "suggestion_key": None, } for item in archived_items[:2] ] def build_dashboard_hints(today: date) -> list[str]: settings = get_user_settings() if not settings.get("reminders_enabled"): return [] hints: list[str] = [] tomorrow = today + timedelta(days=1) household_settings = get_household_settings() if settings.get("remind_tomorrow_if_sparse"): breakfast = get_db().execute( f""" SELECT COUNT(*) AS count FROM plan_entries JOIN dayparts ON dayparts.id = plan_entries.daypart_id WHERE plan_entries.plan_date = ? AND dayparts.slug = 'breakfast' AND {visible_clause('plan_entries')} """, [tomorrow.isoformat(), *visible_params()], ).fetchone() if int(breakfast["count"]) == 0: hints.append("Für morgen ist noch kein Frühstück eingeplant.") if settings.get("suggest_home_for_today"): dinner_home = get_db().execute( f""" SELECT COUNT(*) AS count 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 = 'home' AND items.is_archived = 0 AND dayparts.slug = 'dinner' AND {visible_clause('items')} """, visible_params(), ).fetchone() if int(dinner_home["count"]) > 0: hints.append("Für heute Abend ist zuhause schon etwas Passendes da.") if settings.get("remind_before_shopping") and (today + timedelta(days=1)).weekday() == household_settings["shopping_weekday"]: upcoming = fetch_upcoming_shopping_needs(limit=3) if upcoming: hints.append("Für den nächsten Einkauf sind schon ein paar Dinge vorgemerkt.") if settings.get("remind_nuts"): nut_items = [item for item in fetch_items(kind="food", availability="home") if {"nuts", "seeds"} & set(item.get("builder_keys", []))] if nut_items: hints.append("Vielleicht passt heute noch etwas mit Nüssen oder Saaten dazu.") if settings.get("suggest_templates"): old_template = get_db().execute( f""" SELECT name FROM day_templates WHERE {visible_clause('day_templates')} AND last_used_at IS NOT NULL AND DATE(last_used_at) <= DATE('now', '-21 day') ORDER BY last_used_at ASC LIMIT 1 """, visible_params(), ).fetchone() if old_template: hints.append(f"„{old_template['name']}“ könnte diese Woche wieder passen.") return hints[:4] def build_setup_checklist(today: date) -> list[dict]: total_items = int( get_db().execute( 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 items.is_archived = 0 AND {visible_clause('items')}", visible_params(), ).fetchone()["count"] ) week_end = today + timedelta(days=6) plan_count = int( get_db().execute( f""" SELECT COUNT(*) AS count FROM plan_entries WHERE plan_date BETWEEN ? AND ? AND {visible_clause('plan_entries')} """, [today.isoformat(), week_end.isoformat(), *visible_params()], ).fetchone()["count"] ) template_count = int( get_db().execute( f"SELECT COUNT(*) AS count FROM day_templates WHERE {visible_clause('day_templates')}", visible_params(), ).fetchone()["count"] ) checklist = [] if total_items == 0: checklist.append( { "title": "Fang mit einem ersten Lebensmittel an", "text": "Ein kleines Frühstück, ein Snack oder etwas für zuhause reicht völlig für den Start.", "url": url_for("main.item_create", kind="food"), "label": "Lebensmittel anlegen", } ) if total_items > 0 and meal_count == 0: checklist.append( { "title": "Lege eine erste Mahlzeitenidee an", "text": "Einfach zwei oder drei vertraute Dinge zusammenklicken und für später merken.", "url": url_for("main.item_create", kind="meal"), "label": "Mahlzeit anlegen", } ) if plan_count == 0: checklist.append( { "title": "Plane einen ruhigen ersten Tag", "text": "Mit einem kleinen Eintrag für Frühstück oder Abendessen fühlt sich die Woche sofort greifbarer an.", "url": url_for("main.planner_day", date=today.isoformat()), "label": "Tag öffnen", } ) if total_items > 0 and template_count == 0: checklist.append( { "title": "Merke dir einen gelungenen Tag als Vorlage", "text": "So wird Wiederverwendung später noch leichter.", "url": url_for("main.template_library"), "label": "Vorlagen ansehen", } ) return checklist[:3] def build_day_hints(selected_date: date) -> list[str]: settings = get_user_settings() if not settings.get("reminders_enabled"): return [] hints: list[str] = [] if settings.get("remind_tomorrow_if_sparse"): breakfast_count = get_db().execute( f""" SELECT COUNT(*) AS count FROM plan_entries JOIN dayparts ON dayparts.id = plan_entries.daypart_id WHERE plan_entries.plan_date = ? AND dayparts.slug = 'breakfast' AND {visible_clause('plan_entries')} """, [selected_date.isoformat(), *visible_params()], ).fetchone() if int(breakfast_count["count"]) == 0: hints.append("Für diesen Tag ist noch kein Frühstück eingeplant.") if settings.get("remind_small_snack"): afternoon_options = get_db().execute( f""" SELECT COUNT(*) AS count FROM items JOIN item_dayparts ON item_dayparts.item_id = items.id JOIN dayparts ON dayparts.id = item_dayparts.daypart_id WHERE items.is_archived = 0 AND dayparts.slug = 'afternoon-snack' AND {visible_clause('items')} """, visible_params(), ).fetchone() if int(afternoon_options["count"]) < 2: hints.append("Für den Nachmittag wäre noch etwas Kleines möglich.") if settings.get("show_planned_not_shopped"): pending_for_day = fetch_upcoming_shopping_needs(limit=20) pending_for_day = [entry for entry in pending_for_day if entry["needed_for_date"] == selected_date.isoformat()] if pending_for_day: hints.append("Ein paar Dinge sind für diesen Tag schon vorgemerkt, aber noch nicht auf der Einkaufsliste.") return hints[:4] def build_week_hints(week_start: date) -> list[str]: settings = get_user_settings() if not settings.get("reminders_enabled"): return [] hints: list[str] = [] week_end = week_start + timedelta(days=6) if settings.get("remind_week_if_sparse"): breakfast_days = get_db().execute( f""" SELECT COUNT(DISTINCT plan_entries.plan_date) AS count FROM plan_entries JOIN dayparts ON dayparts.id = plan_entries.daypart_id WHERE plan_entries.plan_date BETWEEN ? AND ? AND dayparts.slug = 'breakfast' AND {visible_clause('plan_entries')} """, [week_start.isoformat(), week_end.isoformat(), *visible_params()], ).fetchone() missing_breakfasts = 7 - int(breakfast_days["count"]) if missing_breakfasts > 0: hints.append(f"Für diese Woche sind noch {missing_breakfasts} Tage ohne Frühstück eingeplant.") if settings.get("show_missing_for_upcoming_week"): due_entries = [entry for entry in fetch_upcoming_shopping_needs(limit=20) if week_start.isoformat() <= entry["needed_for_date"] <= week_end.isoformat()] if due_entries: hints.append("Für diese Woche fehlt noch etwas, das später zum Einkauf dazukommen kann.") if settings.get("remind_on_shopping_day") and week_start.weekday() <= get_household_settings()["shopping_weekday"] <= week_end.weekday(): hints.append("Der Einkaufstag liegt in dieser Woche schon bereit im Blick.") return hints[:4] def build_home_sections(items: list[dict], dayparts: list, selected_daypart_id: int | None): sections = [] if selected_daypart_id: selected_daypart = next((daypart for daypart in dayparts if daypart["id"] == selected_daypart_id), None) matching_items = [item for item in items if any(dp["id"] == selected_daypart_id for dp in item["dayparts_meta"])] sections.append( { "title": selected_daypart["name"] if selected_daypart else "Ausgewählte Tageszeit", "items": matching_items, } ) return sections for daypart in dayparts: matching_items = [item for item in items if any(dp["id"] == daypart["id"] for dp in item["dayparts_meta"])] sections.append({"title": daypart["name"], "items": matching_items}) anytime_items = [item for item in items if not item["dayparts_meta"]] if anytime_items: sections.append({"title": "Ohne feste Tageszeit", "items": anytime_items}) return sections def dedupe_items(items: list[dict], limit: int = 6) -> list[dict]: result = [] seen_ids = set() for item in items: if item["id"] in seen_ids: continue seen_ids.add(item["id"]) result.append(item) if len(result) >= limit: break return result def build_selected_quick_action( *, daypart_id: int, selected_item_id: int | None, selected_meal_name: str, selected_component_ids: list[int], candidates: list[dict], ) -> dict | None: if selected_item_id: selected_item = next((item for item in candidates if int(item["id"]) == int(selected_item_id)), None) if selected_item is None: try: selected_item = get_item(selected_item_id) except ValueError: selected_item = None if selected_item is not None: return { "type": "existing", "title": selected_item["name"], "subtitle": "Ausgewählt. Du kannst es jetzt direkt eintragen.", "item_id": int(selected_item["id"]), "visibility": selected_item["visibility"], "daypart_id": daypart_id, } if selected_meal_name and selected_component_ids: return { "type": "generated", "title": selected_meal_name, "subtitle": "Ausgewählt aus dem, was zuhause gut passt.", "component_ids": selected_component_ids, "visibility": "shared", "daypart_id": daypart_id, } return None def build_day_planner_sections( selected_date: date, selected_item_id: int | None, selected_daypart_id: int | None, selected_meal_name: str = "", selected_component_ids: list[int] | None = None, ): selected_component_ids = selected_component_ids or [] sections = [] day_entries = fetch_day_plan_entries(selected_date) for daypart in get_dayparts(): 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.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.get("is_home")] + fetch_recent_plan_items(daypart["id"]) + [item for item in candidates if item["kind"] == "food"], limit=20, ) search_candidates = dedupe_items(meal_candidates + food_candidates, limit=24) entry_item_ids = [int(entry["item_id"]) for entry in entries] sections.append( { "daypart": daypart, "entries": entries, "candidates": candidates, "meal_candidates": meal_candidates, "food_candidates": food_candidates, "search_candidates": search_candidates, "recipe_suggestions": build_home_recipe_suggestions(int(daypart["id"]), limit=3), "suggestions": build_daypart_suggestions(daypart["id"]), "balance_suggestion": build_balance_suggestion(int(daypart["id"]), entry_item_ids), "selected_item_id": selected_item_id if selected_daypart_id == daypart["id"] else None, "selected_quick_action": build_selected_quick_action( daypart_id=int(daypart["id"]), selected_item_id=selected_item_id if selected_daypart_id == daypart["id"] else None, selected_meal_name=selected_meal_name if selected_daypart_id == daypart["id"] else "", selected_component_ids=selected_component_ids if selected_daypart_id == daypart["id"] else [], candidates=candidates, ), "is_open": selected_daypart_id == daypart["id"], "is_primary_daypart": daypart["slug"] in PRIMARY_DAYPART_SLUGS, "is_snack_daypart": daypart["slug"] in SNACK_DAYPART_SLUGS, "visible_by_default": daypart["slug"] in PRIMARY_DAYPART_SLUGS or bool(entries) or selected_daypart_id == daypart["id"], "summary_items": [entry["item_name"] for entry in entries][:2], "default_visibility": "shared", } ) return sections def build_template_day_sections(selected_map: dict[int, list[int]] | None = None): selected_map = selected_map or {} sections = [] for daypart in get_dayparts(): candidates = fetch_plan_candidates(int(daypart["id"])) quick_items = dedupe_items( [item for item in candidates if item.get("is_home")] + candidates, limit=10, ) quick_ids = {item["id"] for item in quick_items} sections.append( { "daypart": daypart, "candidates": candidates, "quick_items": quick_items, "list_items": [item for item in candidates if item["id"] not in quick_ids], "selected_ids": selected_map.get(int(daypart["id"]), []), } ) return sections def fetch_week_cards(week_start: date): days = [week_start + timedelta(days=index) for index in range(7)] week_end = week_start + timedelta(days=6) grouped_entries = fetch_plan_entries_for_range(week_start, week_start + timedelta(days=6)) picker_map = {} 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.get("is_home")] + [item for item in candidates if item["kind"] == "meal"], limit=4, ) picker_map[int(daypart["id"])] = { "meal_candidates": meal_candidates, "recipe_suggestions": build_home_recipe_suggestions(int(daypart["id"]), limit=3), } cards = [] for current_day in days: filled_dayparts = [] planned_count = 0 preview_items = [] slots = [] for daypart in get_dayparts(): slot_entries = grouped_entries.get((current_day.isoformat(), daypart["id"]), []) is_snack_daypart = daypart["slug"] in SNACK_DAYPART_SLUGS visible_by_default = (not is_snack_daypart) or bool(slot_entries) slots.append( { "daypart": dict(daypart), "entries": slot_entries, "copy_allowed": bool(slot_entries) and current_day < week_end, "picker": picker_map.get(int(daypart["id"]), {"meal_candidates": [], "recipe_suggestions": []}), "is_snack_daypart": is_snack_daypart, "visible_by_default": visible_by_default, } ) if slot_entries: filled_dayparts.append({"id": daypart["id"], "name": daypart["name"], "count": len(slot_entries)}) planned_count += len(slot_entries) preview_items.extend(entry["item_name"] for entry in slot_entries[:2]) hidden_snack_slots = [ {"id": int(slot["daypart"]["id"]), "name": slot["daypart"]["name"]} for slot in slots if slot["is_snack_daypart"] and not slot["visible_by_default"] ] cards.append( { "date": current_day, "filled_dayparts": filled_dayparts, "planned_count": planned_count, "preview_items": preview_items[:4], "slots": slots, "hidden_snack_slots": hidden_snack_slots, } ) return cards def format_week_pdf_entry(entry: dict) -> str: return entry["item_name"] def normalize_pdf_export_mode(raw: str | None) -> str: return "household" if raw == "household" else "mine" def fetch_plan_entries_for_range_export(start_date: date, end_date: date, *, mode: str): params: list[object] = [start_date.isoformat(), end_date.isoformat()] if mode == "household": where_clause = "plan_entries.household_id = ?" params.append(current_household_id()) else: where_clause = visible_clause("plan_entries") params.extend(visible_params()) rows = get_db().execute( f""" SELECT plan_entries.*, items.name AS item_name, 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, owner.display_name AS owner_display_name, owner.username AS owner_username, target.display_name AS target_display_name, target.username AS target_username FROM plan_entries JOIN items ON items.id = plan_entries.item_id JOIN dayparts ON dayparts.id = plan_entries.daypart_id LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id LEFT JOIN users AS target ON target.id = items.target_user_id WHERE plan_date BETWEEN ? AND ? AND {where_clause} ORDER BY plan_date, dayparts.sort_order, items.name """, params, ).fetchall() grouped = defaultdict(list) for row in describe_records(rows): grouped[(row["plan_date"], row["daypart_id"])].append(row) return grouped def format_week_pdf_entry(entry: dict, *, mode: str) -> str: label = format_pdf_cell_label(entry["item_name"]) if mode == "household": if entry.get("target_name"): return f"{label} (Für {entry['target_name']})" if entry.get("is_personal"): return f"{label} (Für {entry['owner_name']})" return f"{label} (Für alle)" if entry.get("is_shared"): return f"{label} (gemeinsam)" return label def format_pdf_cell_label(label: str) -> str: cleaned = " ".join((label or "").split()) if not cleaned: return "" if " - " in cleaned: return cleaned.replace(" - ", "\n") if ", " in cleaned and len(cleaned) > 20: return cleaned.replace(", ", ",\n") if "-" in cleaned: return "-\n".join(part for part in cleaned.split("-") if part) return cleaned def build_week_pdf_rows(week_start: date, *, mode: str) -> tuple[list, list[list[str]]]: days = [week_start + timedelta(days=index) for index in range(7)] grouped_entries = fetch_plan_entries_for_range_export(week_start, week_start + timedelta(days=6), mode=mode) rows: list[list[str]] = [] dayparts = get_dayparts() visible_dayparts = [] for daypart in dayparts: row_cells: list[str] = [] has_content = False row_label = PDF_DAYPART_LABELS.get(daypart["slug"], daypart["name"]) row = [daypart["name"]] for current_day in days: entries = grouped_entries.get((current_day.isoformat(), daypart["id"]), []) cell_value = "\n".join(format_week_pdf_entry(entry, mode=mode) for entry in entries) row_cells.append(cell_value) has_content = has_content or bool(cell_value.strip()) if daypart["slug"] in PRIMARY_DAYPART_SLUGS or has_content: visible_dayparts.append([row_label, *row_cells]) return days, visible_dayparts def build_week_plan_pdf(week_start: date, *, mode: str = "mine") -> bytes: try: from fpdf import FPDF from fpdf.fonts import FontFace except ImportError as exc: # pragma: no cover - depends on optional package in local env raise RuntimeError("Für den PDF-Export fehlt noch die Abhängigkeit aus der requirements.txt.") from exc mode = normalize_pdf_export_mode(mode) week_end = week_start + timedelta(days=6) week_number = week_start.isocalendar().week days, rows = build_week_pdf_rows(week_start, mode=mode) plan_label = "Mein Essensplan" if mode == "mine" else "Unser Essensplan" pdf = FPDF(orientation="L", unit="mm", format="A4") pdf.set_auto_page_break(auto=True, margin=14) pdf.set_margins(left=14, top=14, right=14) pdf.add_page() pdf.set_title(f"{plan_label} KW {week_number:02d}") pdf.set_author("Nouri") pdf.set_creator("Nouri") pdf.set_font("Helvetica", "B", 18) pdf.cell(0, 9, f"{plan_label} vom {week_start.strftime('%d.%m.%Y')} bis {week_end.strftime('%d.%m.%Y')}", new_x="LMARGIN", new_y="NEXT") pdf.set_font("Helvetica", "", 11) pdf.set_text_color(82, 82, 82) pdf.cell(0, 6, f"KW {week_number:02d}", new_x="LMARGIN", new_y="NEXT") pdf.ln(3) pdf.set_text_color(20, 20, 20) pdf.set_font("Helvetica", "", 10) headings = [" "] + [f"{format_weekday(day)}\n{day.strftime('%d.%m.%Y')}" for day in days] first_column_width = 34 remaining_width = pdf.w - pdf.l_margin - pdf.r_margin - first_column_width day_column_width = remaining_width / 7 column_widths = (first_column_width, *([day_column_width] * 7)) header_style = FontFace(emphasis="B", fill_color=(240, 240, 240)) first_column_style = FontFace(emphasis="B", fill_color=(248, 248, 248)) body_style = FontFace(fill_color=(255, 255, 255)) with pdf.table( borders_layout="SINGLE_TOP_LINE", cell_fill_color=(255, 255, 255), cell_fill_mode="ROWS", col_widths=column_widths, gutter_height=0, gutter_width=0, headings_style=header_style, line_height=5.5, text_align=("LEFT", "LEFT", "LEFT", "LEFT", "LEFT", "LEFT", "LEFT", "LEFT"), width=pdf.w - pdf.l_margin - pdf.r_margin, ) as table: header_row = table.row() for heading in headings: header_row.cell(heading, padding=(2.8, 2.5, 2.8, 2.5), v_align="M") for row in rows: table_row = table.row() table_row.cell(row[0], style=first_column_style, padding=(2.5, 2.8, 2.5, 2.8), v_align="M") for value in row[1:]: table_row.cell(value or " ", style=body_style, padding=(2.5, 2.8, 2.5, 2.8), v_align="TOP") return bytes(pdf.output()) def count_visible_items(availability_state: str) -> int: 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"]) def fetch_day_templates(query: str | None = None, visibility: str | None = None) -> list[dict]: conditions = [visible_clause("day_templates")] params = visible_params() if query: conditions.append("LOWER(day_templates.name) LIKE ?") params.append(f"%{query.lower()}%") if visibility: conditions.append("day_templates.visibility = ?") params.append(visibility) rows = get_db().execute( f""" SELECT day_templates.*, owner.display_name AS owner_display_name, owner.username AS owner_username, ( SELECT COUNT(*) FROM day_template_entries WHERE day_template_entries.day_template_id = day_templates.id ) AS entry_count FROM day_templates LEFT JOIN users AS owner ON owner.id = day_templates.owner_user_id WHERE {' AND '.join(conditions)} ORDER BY LOWER(day_templates.name) """, params, ).fetchall() return [describe_template_record(dict(row)) for row in rows] def get_day_template(template_id: int) -> dict: row = get_db().execute( f""" SELECT day_templates.*, owner.display_name AS owner_display_name, owner.username AS owner_username FROM day_templates LEFT JOIN users AS owner ON owner.id = day_templates.owner_user_id WHERE day_templates.id = ? AND {visible_clause('day_templates')} """, [template_id, *visible_params()], ).fetchone() if row is None: raise ValueError("Die Tagesvorlage wurde nicht gefunden.") return describe_template_record(dict(row)) def get_day_template_selected_map(template_id: int) -> dict[int, list[int]]: rows = get_db().execute( """ SELECT daypart_id, item_id FROM day_template_entries WHERE day_template_id = ? ORDER BY sort_order, id """, (template_id,), ).fetchall() grouped: dict[int, list[int]] = defaultdict(list) for row in rows: grouped[int(row["daypart_id"])].append(int(row["item_id"])) return grouped def sync_day_template_entries(template_id: int, selected_map: dict[int, list[int]]) -> None: get_db().execute("DELETE FROM day_template_entries WHERE day_template_id = ?", (template_id,)) for daypart in get_dayparts(): for sort_order, item_id in enumerate(selected_map.get(int(daypart["id"]), []), start=10): get_db().execute( """ INSERT INTO day_template_entries (day_template_id, daypart_id, item_id, sort_order) VALUES (?, ?, ?, ?) """, (template_id, daypart["id"], item_id, sort_order), ) def day_template_form_data(template: dict | None = None, source_date: date | None = None) -> dict: selected_map: dict[int, list[int]] = defaultdict(list) if template: selected_map.update(get_day_template_selected_map(template["id"])) elif source_date: for (plan_date_key, daypart_id), entries in fetch_day_plan_entries(source_date).items(): if plan_date_key == source_date.isoformat(): selected_map[int(daypart_id)] = [int(entry["item_id"]) for entry in entries] form_data: dict[str, object] = { "name": template["name"] if template else "", "description": template["description"] if template else "", "visibility": template["visibility"] if template else "shared", "selected_map": {key: value[:] for key, value in selected_map.items()}, } return form_data def extract_day_template_form_data(existing: dict | None = None) -> dict: form_data = existing or {} selected_map: dict[int, list[int]] = {} for daypart in get_dayparts(): selected_map[int(daypart["id"])] = [ int(value) for value in request.form.getlist(f"daypart_{daypart['id']}_item_ids") if value.isdigit() ] form_data.update( { "name": request.form.get("name", "").strip(), "description": request.form.get("description", "").strip(), "visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")), "selected_map": selected_map, } ) return form_data def apply_day_template(template_id: int, selected_date: date) -> int: template = get_day_template(template_id) entries_map = get_day_template_selected_map(template_id) inserted = 0 for daypart_id, item_ids in entries_map.items(): for item_id in item_ids: existing = get_db().execute( """ SELECT id FROM plan_entries WHERE plan_date = ? AND daypart_id = ? AND item_id = ? AND visibility = ? """, (selected_date.isoformat(), daypart_id, item_id, template["visibility"]), ).fetchone() if existing: continue get_db().execute( """ INSERT INTO plan_entries (household_id, owner_user_id, visibility, plan_date, daypart_id, item_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( current_household_id(), g.user["id"], template["visibility"], selected_date.isoformat(), daypart_id, item_id, g.user["id"], ), ) insert_result = ensure_item_or_missing_components_are_shopped( item_id, g.user["id"], template["visibility"], needed_for_date=selected_date.isoformat(), needed_for_daypart_id=daypart_id, source_item_id=item_id, ) inserted += 1 get_db().commit() get_db().execute( "UPDATE day_templates SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?", (template_id,), ) get_db().commit() return inserted def create_day_template_snapshot(name: str, description: str, visibility: str, source_date: date) -> int | None: selected_map = day_template_form_data(source_date=source_date)["selected_map"] if not any(selected_map.values()): return None cursor = get_db().execute( """ INSERT INTO day_templates (household_id, owner_user_id, visibility, name, description) VALUES (?, ?, ?, ?, ?) """, (current_household_id(), g.user["id"], visibility, name, description), ) template_id = int(cursor.lastrowid) sync_day_template_entries(template_id, selected_map) return template_id def fetch_week_templates(query: str | None = None, visibility: str | None = None) -> list[dict]: conditions = [visible_clause("week_templates")] params = visible_params() if query: conditions.append("LOWER(week_templates.name) LIKE ?") params.append(f"%{query.lower()}%") if visibility: conditions.append("week_templates.visibility = ?") params.append(visibility) rows = get_db().execute( f""" SELECT week_templates.*, owner.display_name AS owner_display_name, owner.username AS owner_username, ( SELECT COUNT(*) FROM week_template_days WHERE week_template_days.week_template_id = week_templates.id ) AS day_count FROM week_templates LEFT JOIN users AS owner ON owner.id = week_templates.owner_user_id WHERE {' AND '.join(conditions)} ORDER BY LOWER(week_templates.name) """, params, ).fetchall() return [describe_template_record(dict(row)) for row in rows] def get_week_template(template_id: int) -> dict: row = get_db().execute( f""" SELECT week_templates.*, owner.display_name AS owner_display_name, owner.username AS owner_username FROM week_templates LEFT JOIN users AS owner ON owner.id = week_templates.owner_user_id WHERE week_templates.id = ? AND {visible_clause('week_templates')} """, [template_id, *visible_params()], ).fetchone() if row is None: raise ValueError("Die Wochenvorlage wurde nicht gefunden.") return describe_template_record(dict(row)) def get_week_template_selected_map(template_id: int) -> dict[int, int]: rows = get_db().execute( """ SELECT weekday_index, day_template_id FROM week_template_days WHERE week_template_id = ? ORDER BY weekday_index """, (template_id,), ).fetchall() return {int(row["weekday_index"]): int(row["day_template_id"]) for row in rows} def week_template_form_data(template: dict | None = None, source_week: date | None = None) -> dict: selected_map = get_week_template_selected_map(template["id"]) if template else {} source_days = {} if source_week: for weekday_index in range(7): source_days[weekday_index] = fetch_day_plan_entries(source_week + timedelta(days=weekday_index)) return { "name": template["name"] if template else "", "description": template["description"] if template else "", "visibility": template["visibility"] if template else "shared", "selected_map": selected_map, "source_week": source_week.isoformat() if source_week else "", "copy_from_source": { index: bool(source_days.get(index)) for index in range(7) }, } def extract_week_template_form_data(existing: dict | None = None) -> dict: form_data = existing or {} selected_map: dict[int, int | None] = {} copy_from_source: dict[int, bool] = {} for weekday_index in range(7): raw_value = request.form.get(f"weekday_{weekday_index}_day_template_id", "").strip() selected_map[weekday_index] = int(raw_value) if raw_value.isdigit() else None copy_from_source[weekday_index] = request.form.get(f"weekday_{weekday_index}_copy_source") == "1" form_data.update( { "name": request.form.get("name", "").strip(), "description": request.form.get("description", "").strip(), "visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")), "selected_map": selected_map, "source_week": request.form.get("source_week", "").strip(), "copy_from_source": copy_from_source, } ) return form_data def sync_week_template_days(template_id: int, selected_map: dict[int, int | None]) -> None: get_db().execute("DELETE FROM week_template_days WHERE week_template_id = ?", (template_id,)) for weekday_index, day_template_id in selected_map.items(): if day_template_id: get_db().execute( """ INSERT INTO week_template_days (week_template_id, weekday_index, day_template_id) VALUES (?, ?, ?) """, (template_id, weekday_index, day_template_id), ) def apply_week_template(template_id: int, week_start: date) -> int: selected_map = get_week_template_selected_map(template_id) inserted = 0 for weekday_index, day_template_id in selected_map.items(): inserted += apply_day_template(day_template_id, week_start + timedelta(days=weekday_index)) get_db().execute( "UPDATE week_templates SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?", (template_id,), ) get_db().commit() return inserted def fetch_item_sets(query: str | None = None, visibility: str | None = None) -> list[dict]: conditions = [visible_clause("item_sets")] params = visible_params() if query: conditions.append("LOWER(item_sets.name) LIKE ?") params.append(f"%{query.lower()}%") if visibility: conditions.append("item_sets.visibility = ?") params.append(visibility) rows = get_db().execute( f""" SELECT item_sets.*, owner.display_name AS owner_display_name, owner.username AS owner_username, ( SELECT COUNT(*) FROM item_set_items WHERE item_set_items.item_set_id = item_sets.id ) AS item_count FROM item_sets LEFT JOIN users AS owner ON owner.id = item_sets.owner_user_id WHERE {' AND '.join(conditions)} ORDER BY LOWER(item_sets.name) """, params, ).fetchall() return [describe_template_record(dict(row)) for row in rows] def get_item_set(set_id: int) -> dict: row = get_db().execute( f""" SELECT item_sets.*, owner.display_name AS owner_display_name, owner.username AS owner_username FROM item_sets LEFT JOIN users AS owner ON owner.id = item_sets.owner_user_id WHERE item_sets.id = ? AND {visible_clause('item_sets')} """, [set_id, *visible_params()], ).fetchone() if row is None: raise ValueError("Das Paket wurde nicht gefunden.") return describe_template_record(dict(row)) def get_item_set_selected_ids(set_id: int) -> list[int]: rows = get_db().execute( """ SELECT item_id FROM item_set_items WHERE item_set_id = ? ORDER BY sort_order, id """, (set_id,), ).fetchall() return [int(row["item_id"]) for row in rows] def sync_item_set_items(set_id: int, item_ids: list[int]) -> None: get_db().execute("DELETE FROM item_set_items WHERE item_set_id = ?", (set_id,)) for sort_order, item_id in enumerate(item_ids, start=10): get_db().execute( """ INSERT OR IGNORE INTO item_set_items (item_set_id, item_id, sort_order) VALUES (?, ?, ?) """, (set_id, item_id, sort_order), ) def extract_item_set_form_data(existing: dict | None = None) -> dict: form_data = existing or {} item_ids = [int(value) for value in request.form.getlist("item_ids") if value.isdigit()] remove_item_id = request.form.get("remove_item_id", "").strip() if remove_item_id.isdigit(): item_ids = [item_id for item_id in item_ids if item_id != int(remove_item_id)] form_data.update( { "name": request.form.get("name", "").strip(), "description": request.form.get("description", "").strip(), "visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")), "item_ids": item_ids, "item_search": request.form.get("item_search", "").strip(), } ) return form_data def apply_item_set_to_shopping(set_id: int) -> dict: item_set = get_item_set(set_id) selected_ids = get_item_set_selected_ids(set_id) added_names: list[str] = [] for item_id in selected_ids: result = ensure_item_or_missing_components_are_shopped( item_id, g.user["id"], item_set["visibility"], ) added_names.extend(result["names"]) get_db().execute( "UPDATE item_sets SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?", (set_id,), ) get_db().commit() return {"count": len(added_names), "names": added_names} def render_item_form(kind: str, *, item: dict | None, form_data: dict): foods = fetch_food_options() if kind == "meal" else [] return render_template( "items/form.html", kind=kind, item=item, dayparts=get_dayparts(), food_groups=group_items_by_availability(foods), selected_components=fetch_items_by_ids(form_data.get("component_ids", [])) if kind == "meal" else [], categories=get_category_options( form_data.get("category") or form_data.get("quick_food_category") ), 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, meal_type_options=MEAL_TYPE_OPTIONS, meal_style_options=MEAL_STYLE_OPTIONS, energy_density_options=ENERGY_DENSITY_OPTIONS, visibility_options=VISIBILITY_FORM_OPTIONS, target_user_options=get_target_user_options(), ) def create_or_get_generated_meal( *, name: str, component_ids: list[int], daypart_id: int, visibility: str, ) -> int: normalized_ids = normalized_component_signature(component_ids) existing_meals = [ item for item in fetch_items(kind="meal") if normalized_component_signature(item.get("component_ids", [])) == normalized_ids ] if existing_meals: meal_id = int(existing_meals[0]["id"]) current_dayparts = get_item_daypart_ids(meal_id) if daypart_id not in current_dayparts: sync_item_dayparts(meal_id, current_dayparts + [daypart_id]) get_db().execute( """ UPDATE items SET updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, (g.user["id"], meal_id), ) get_db().commit() return meal_id existing = get_db().execute( f""" SELECT items.id FROM items WHERE items.kind = 'meal' AND LOWER(items.name) = LOWER(?) AND {visible_clause('items')} ORDER BY items.id LIMIT 1 """, [name, *visible_params()], ).fetchone() if existing: meal_id = int(existing["id"]) sync_meal_components(meal_id, list(normalized_ids)) current_dayparts = get_item_daypart_ids(meal_id) if daypart_id not in current_dayparts: sync_item_dayparts(meal_id, current_dayparts + [daypart_id]) get_db().execute( """ UPDATE items SET meal_type = COALESCE(meal_type, ?), meal_tags = CASE WHEN COALESCE(meal_tags, '') = '' THEN ? ELSE meal_tags END, updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, ( meal_type_for_daypart(daypart_id), encode_tag_list(meal_tags_for_generated_meal(daypart_id, fetch_items_by_ids(list(normalized_ids)))), g.user["id"], meal_id, ), ) get_db().commit() return meal_id component_foods = fetch_items_by_ids(list(normalized_ids)) cursor = get_db().execute( """ INSERT INTO items ( household_id, owner_user_id, visibility, kind, name, category, meal_type, meal_tags, energy_density, created_by, updated_by ) VALUES (?, ?, ?, 'meal', ?, ?, ?, ?, ?, ?, ?) """, ( current_household_id(), g.user["id"], visibility, name, None, meal_type_for_daypart(daypart_id), encode_tag_list(meal_tags_for_generated_meal(daypart_id, component_foods)), "neutral", g.user["id"], g.user["id"], ), ) meal_id = int(cursor.lastrowid) sync_item_dayparts(meal_id, [daypart_id]) sync_meal_components(meal_id, list(normalized_ids)) get_db().commit() return meal_id def insert_plan_entry(*, item_id: int, daypart_id: int, plan_date: date, visibility: str, note: str = "") -> dict: get_db().execute( """ INSERT INTO plan_entries (household_id, owner_user_id, visibility, plan_date, daypart_id, item_id, note, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( current_household_id(), g.user["id"], visibility, plan_date.isoformat(), daypart_id, item_id, note, g.user["id"], ), ) get_db().commit() return ensure_item_or_missing_components_are_shopped( item_id, g.user["id"], visibility, needed_for_date=plan_date.isoformat(), needed_for_daypart_id=daypart_id, source_item_id=item_id, ) def update_plan_entry(entry_id: int, *, visibility: str, note: str) -> None: get_db().execute( """ UPDATE plan_entries SET visibility = ?, owner_user_id = CASE WHEN ? = 'personal' THEN ? ELSE owner_user_id END, note = ? WHERE id = ? """, (visibility, visibility, g.user["id"], note, entry_id), ) get_db().commit() def planner_template_options(): return fetch_day_templates() @main_bp.get("/") @login_required def dashboard(): today = date.today() home_count = count_visible_items("home") archive_count = count_visible_items("archived") shopping_count = get_db().execute( f"SELECT COUNT(*) AS count FROM shopping_entries WHERE is_checked = 0 AND {visible_clause('shopping_entries')}", visible_params(), ).fetchone()["count"] today_entries = [] for entries in fetch_day_plan_entries(today).values(): today_entries.extend(entries) today_entries.sort(key=lambda entry: (entry["sort_order"], entry["item_name"].lower())) week_cards = fetch_week_cards(today - timedelta(days=today.weekday())) home_items = fetch_items(availability="home") return render_template( "dashboard.html", home_count=home_count, shopping_count=shopping_count, archive_count=archive_count, today_entries=today_entries, home_items=home_items[:8], today=today, week_cards=week_cards[:3], dashboard_hints=build_dashboard_hints(today), recipe_suggestions=build_home_recipe_suggestions(limit=4), upcoming_entries=fetch_upcoming_shopping_needs(limit=4), day_templates=fetch_day_templates()[:3], week_templates=fetch_week_templates()[:3], setup_checklist=build_setup_checklist(today), ) @main_bp.get("/templates") @login_required def template_library(): query = request.args.get("q", "").strip() selected_visibility = request.args.get("visibility", "").strip() visibility = selected_visibility or None day_templates = fetch_day_templates(query=query or None, visibility=visibility) week_templates = fetch_week_templates(query=query or None, visibility=visibility) item_sets = fetch_item_sets(query=query or None, visibility=visibility) template_hints = build_dashboard_hints(date.today()) return render_template( "library/index.html", query=query, selected_visibility=selected_visibility, visibility_options=VISIBILITY_FILTER_OPTIONS, day_templates=day_templates, week_templates=week_templates, item_sets=item_sets, template_hints=template_hints, ) @main_bp.post("/suggestions/hide") @login_required def suggestion_hide(): component_ids = [int(value) for value in request.form.getlist("component_ids") if value.isdigit()] if not component_ids: flash("Diese Kombination konnte gerade nicht ausgeblendet werden.", "error") return redirect(request.referrer or url_for("main.dashboard")) get_db().execute( """ INSERT OR IGNORE INTO hidden_generated_suggestions (user_id, suggestion_key) VALUES (?, ?) """, (g.user["id"], generated_suggestion_key(component_ids)), ) get_db().commit() flash("Diese generierte Mahlzeit wird dir künftig nicht mehr vorgeschlagen.", "info") return redirect(request.referrer or url_for("main.dashboard")) @main_bp.route("/templates/day/new", methods=("GET", "POST")) @login_required def day_template_create(): source_date = parse_plan_date(request.values.get("source_date"), fallback=None) if request.values.get("source_date") else None form_data = day_template_form_data(source_date=source_date) if request.method == "POST": form_data = extract_day_template_form_data(form_data) if not form_data["name"]: flash("Bitte einen Namen für die Tagesvorlage eintragen.", "error") else: cursor = get_db().execute( """ INSERT INTO day_templates (household_id, owner_user_id, visibility, name, description) VALUES (?, ?, ?, ?, ?) """, ( current_household_id(), g.user["id"], form_data["visibility"], form_data["name"], form_data["description"], ), ) template_id = int(cursor.lastrowid) 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", template=None, form_data=form_data, dayparts=get_dayparts(), daypart_sections=build_template_day_sections(form_data["selected_map"]), visibility_options=VISIBILITY_FORM_OPTIONS, name_suggestions=DAY_TEMPLATE_NAME_SUGGESTIONS, source_date=source_date, ) @main_bp.route("/templates/day//edit", methods=("GET", "POST")) @login_required def day_template_edit(template_id: int): try: template = get_day_template(template_id) ensure_can_edit(template, "Diese Tagesvorlage kannst du gerade nicht bearbeiten.") except (ValueError, PermissionError) as exc: flash(str(exc), "error") return redirect(url_for("main.template_library")) form_data = day_template_form_data(template=template) if request.method == "POST": form_data = extract_day_template_form_data(form_data) if not form_data["name"]: flash("Bitte einen Namen für die Tagesvorlage eintragen.", "error") else: get_db().execute( """ UPDATE day_templates SET name = ?, description = ?, visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, ( form_data["name"], form_data["description"], form_data["visibility"], template_id, ), ) 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", template=template, form_data=form_data, dayparts=get_dayparts(), daypart_sections=build_template_day_sections(form_data["selected_map"]), visibility_options=VISIBILITY_FORM_OPTIONS, name_suggestions=DAY_TEMPLATE_NAME_SUGGESTIONS, source_date=None, ) @main_bp.post("/templates/day//apply") @login_required def day_template_apply(template_id: int): selected_date = parse_plan_date(request.form.get("target_date")) inserted = apply_day_template(template_id, selected_date) flash(f"Die Tagesvorlage wurde angewendet und hat {inserted} Einträge ergänzt.", "success") return redirect(url_for("main.planner_day", date=selected_date.isoformat())) @main_bp.route("/templates/week/new", methods=("GET", "POST")) @login_required def week_template_create(): source_week_raw = request.values.get("source_week", "").strip() source_week = parse_week_start(source_week_raw) if source_week_raw else None form_data = week_template_form_data(source_week=source_week) if request.method == "POST": form_data = extract_week_template_form_data(form_data) if not form_data["name"]: flash("Bitte einen Namen für die Wochenvorlage eintragen.", "error") else: cursor = get_db().execute( """ INSERT INTO week_templates (household_id, owner_user_id, visibility, name, description) VALUES (?, ?, ?, ?, ?) """, ( current_household_id(), g.user["id"], form_data["visibility"], form_data["name"], form_data["description"], ), ) template_id = int(cursor.lastrowid) selected_map = dict(form_data["selected_map"]) if form_data["source_week"]: source_start = parse_week_start(form_data["source_week"]) for weekday_index in range(7): if selected_map.get(weekday_index): continue if form_data["copy_from_source"].get(weekday_index): snapshot_date = source_start + timedelta(days=weekday_index) snapshot_name = f"{form_data['name']} · {WEEKDAY_LABELS[weekday_index]}" snapshot_id = create_day_template_snapshot( snapshot_name, f"Automatisch aus der Woche {source_start.strftime('%d.%m.%Y')} übernommen.", form_data["visibility"], snapshot_date, ) if snapshot_id: selected_map[weekday_index] = snapshot_id 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", template=None, form_data=form_data, visibility_options=VISIBILITY_FORM_OPTIONS, name_suggestions=WEEK_TEMPLATE_NAME_SUGGESTIONS, day_templates=fetch_day_templates(), weekday_labels=WEEKDAY_LABELS, source_week=source_week, ) @main_bp.route("/templates/week//edit", methods=("GET", "POST")) @login_required def week_template_edit(template_id: int): try: template = get_week_template(template_id) ensure_can_edit(template, "Diese Wochenvorlage kannst du gerade nicht bearbeiten.") except (ValueError, PermissionError) as exc: flash(str(exc), "error") return redirect(url_for("main.template_library")) form_data = week_template_form_data(template=template) if request.method == "POST": form_data = extract_week_template_form_data(form_data) if not form_data["name"]: flash("Bitte einen Namen für die Wochenvorlage eintragen.", "error") else: get_db().execute( """ UPDATE week_templates SET name = ?, description = ?, visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, ( form_data["name"], form_data["description"], form_data["visibility"], template_id, ), ) 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", template=template, form_data=form_data, visibility_options=VISIBILITY_FORM_OPTIONS, name_suggestions=WEEK_TEMPLATE_NAME_SUGGESTIONS, day_templates=fetch_day_templates(), weekday_labels=WEEKDAY_LABELS, source_week=None, ) @main_bp.post("/templates/week//apply") @login_required def week_template_apply(template_id: int): week_start = parse_week_start(request.form.get("target_week")) inserted = apply_week_template(template_id, week_start) flash(f"Die Wochenvorlage wurde angewendet und hat {inserted} Einträge ergänzt.", "success") return redirect(url_for("main.planner", week=week_start.isoformat())) @main_bp.route("/templates/set/new", methods=("GET", "POST")) @login_required def item_set_create(): form_data = { "name": "", "description": "", "visibility": "shared", "item_ids": [], "item_search": "", } if request.method == "POST": form_data = extract_item_set_form_data(form_data) if not form_data["name"]: flash("Bitte einen Namen für das Paket eintragen.", "error") else: cursor = get_db().execute( """ INSERT INTO item_sets (household_id, owner_user_id, visibility, name, description) VALUES (?, ?, ?, ?, ?) """, ( current_household_id(), g.user["id"], form_data["visibility"], form_data["name"], form_data["description"], ), ) set_id = int(cursor.lastrowid) 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( "library/set_form.html", item_set=None, form_data=form_data, visibility_options=VISIBILITY_FORM_OPTIONS, name_suggestions=ITEM_SET_NAME_SUGGESTIONS, item_groups=group_items_by_availability(items), selected_items=fetch_items_by_ids(form_data["item_ids"]), ) @main_bp.route("/templates/set//edit", methods=("GET", "POST")) @login_required def item_set_edit(set_id: int): try: item_set = get_item_set(set_id) ensure_can_edit(item_set, "Dieses Paket kannst du gerade nicht bearbeiten.") except (ValueError, PermissionError) as exc: flash(str(exc), "error") return redirect(url_for("main.template_library")) form_data = { "name": item_set["name"], "description": item_set["description"] or "", "visibility": item_set["visibility"], "item_ids": get_item_set_selected_ids(set_id), "item_search": "", } if request.method == "POST": form_data = extract_item_set_form_data(form_data) if not form_data["name"]: flash("Bitte einen Namen für das Paket eintragen.", "error") else: get_db().execute( """ UPDATE item_sets SET name = ?, description = ?, visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, ( form_data["name"], form_data["description"], form_data["visibility"], set_id, ), ) 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( "library/set_form.html", item_set=item_set, form_data=form_data, visibility_options=VISIBILITY_FORM_OPTIONS, name_suggestions=ITEM_SET_NAME_SUGGESTIONS, item_groups=group_items_by_availability(items), selected_items=fetch_items_by_ids(form_data["item_ids"]), ) @main_bp.post("/templates/set//apply") @login_required def item_set_apply(set_id: int): result = apply_item_set_to_shopping(set_id) if result["count"]: flash(f"Das Paket wurde auf die Einkaufsliste übernommen: {', '.join(result['names'][:4])}.", "success") else: flash("Das Paket ist bereits vollständig auf der Einkaufsliste oder zuhause vorhanden.", "info") return redirect(url_for("main.shopping_list")) @main_bp.route("/settings", methods=("GET", "POST")) @login_required def settings_view(): if request.method == "POST": form_name = request.form.get("form_name", "").strip() if form_name == "household": shopping_weekday = normalize_weekday(request.form.get("shopping_weekday"), 5) raw_prep_days = request.form.get("shopping_prep_days", "1").strip() shopping_prep_days = max(0, min(7, int(raw_prep_days))) if raw_prep_days.isdigit() else 1 shopping_reminder_time = request.form.get("shopping_reminder_time", "18:00").strip() or "18:00" get_db().execute( """ UPDATE households SET shopping_weekday = ?, shopping_prep_days = ?, shopping_reminder_time = ? WHERE id = ? """, (shopping_weekday, shopping_prep_days, shopping_reminder_time, current_household_id()), ) 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") protein_preference = normalize_protein_preference(request.form.get("protein_preference"), "mixed") get_db().execute( """ UPDATE user_settings SET reminders_enabled = ?, push_enabled = ?, notification_channel = ?, suggestion_style = ?, energy_preference = ?, protein_preference = ?, remind_before_shopping = ?, remind_on_shopping_day = ?, show_missing_for_upcoming_week = ?, show_planned_not_shopped = ?, remind_tomorrow_if_sparse = ?, remind_week_if_sparse = ?, push_missing_breakfast = ?, push_missing_lunch = ?, push_missing_dinner = ?, push_small_snack = ?, suggest_home_for_today = ?, remind_small_snack = ?, remind_nuts = ?, show_meal_balancing = ?, suggest_templates = ?, suggest_patterns = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? """, ( parse_checkbox("reminders_enabled", True), parse_checkbox("push_enabled", False), normalize_notification_channel(request.form.get("notification_channel"), "in_app"), suggestion_style, suggestion_style_energy_preference(suggestion_style), protein_preference, parse_checkbox("remind_before_shopping", True), parse_checkbox("remind_on_shopping_day", True), parse_checkbox("show_missing_for_upcoming_week", True), parse_checkbox("show_planned_not_shopped", True), parse_checkbox("remind_tomorrow_if_sparse", True), parse_checkbox("remind_week_if_sparse", True), parse_checkbox("push_missing_breakfast", False), parse_checkbox("push_missing_lunch", False), parse_checkbox("push_missing_dinner", False), parse_checkbox("push_small_snack", False), parse_checkbox("suggest_home_for_today", True), parse_checkbox("remind_small_snack", False), parse_checkbox("remind_nuts", False), parse_checkbox("show_meal_balancing", True), parse_checkbox("suggest_templates", True), parse_checkbox("suggest_patterns", True), g.user["id"], ), ) 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( """ SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ? AND is_active = 1 ORDER BY updated_at DESC LIMIT 1 """, (g.user["id"],), ).fetchone() if subscription is None: flash("Bitte aktiviere zuerst Push im Browser oder auf dem Home-Bildschirm.", "error") else: ok, error = send_push_message( { "endpoint": subscription["endpoint"], "keys": {"p256dh": subscription["p256dh"], "auth": subscription["auth"]}, }, title="Nouri", body="Push ist bereit. Erinnerungen können später darüber kommen.", url=url_for("main.settings_view", _external=True), ) if ok: get_db().execute( "UPDATE push_subscriptions SET last_test_at = CURRENT_TIMESTAMP WHERE endpoint = ?", (subscription["endpoint"],), ) get_db().commit() flash("Die Test-Mitteilung wurde versendet.", "success") else: flash(error or "Die Test-Mitteilung konnte gerade nicht gesendet werden.", "error") return redirect(url_for("main.settings_view")) household_settings = get_household_settings() user_settings = get_user_settings() push_subscription = get_db().execute( """ SELECT COUNT(*) AS count FROM push_subscriptions WHERE user_id = ? AND is_active = 1 """, (g.user["id"],), ).fetchone() return render_template( "settings.html", household_settings=household_settings, user_settings=user_settings, push_subscription_count=int(push_subscription["count"]), push_ready=push_is_configured(), push_public_key_value=push_public_key(), restore_confirmation_text=RESTORE_CONFIRMATION_TEXT, ) @main_bp.get("/settings/backup/export") @login_required @admin_required def backup_export(): archive_path, download_name = export_backup_archive( get_db(), current_app.config["UPLOAD_FOLDER"], current_app.config["APP_VERSION"], ) archive_size = Path(archive_path).stat().st_size response = send_file( archive_path, as_attachment=True, download_name=download_name, mimetype="application/zip", max_age=0, ) response.content_length = archive_size response.call_on_close(lambda: Path(archive_path).unlink(missing_ok=True)) return response @main_bp.post("/settings/backup/restore") @login_required @admin_required def backup_restore(): confirmation = request.form.get("restore_confirmation", "").strip().upper() backup_file = request.files.get("backup_file") if confirmation != RESTORE_CONFIRMATION_TEXT: flash("Bitte die Bestätigung genau eintragen, bevor das Backup wiederhergestellt wird.", "error") return redirect(url_for("main.settings_view")) if not backup_file or not backup_file.filename: flash("Bitte zuerst eine Backup-Datei auswählen.", "error") return redirect(url_for("main.settings_view")) try: metadata = restore_backup_archive(get_db(), current_app.config["UPLOAD_FOLDER"], backup_file) get_db().commit() except Exception as exc: get_db().rollback() flash(str(exc) or "Das Backup konnte gerade nicht wiederhergestellt werden.", "error") return redirect(url_for("main.settings_view")) version_label = metadata.get("app_version") or "einer älteren Version" flash(f"Das Backup aus {version_label} wurde wiederhergestellt.", "success") return redirect(url_for("main.settings_view")) @main_bp.post("/push/subscribe") @login_required def push_subscribe(): if not push_is_configured(): return jsonify({"ok": False, "error": "Push ist noch nicht konfiguriert."}), 400 endpoint = request.form.get("endpoint", "").strip() p256dh = request.form.get("p256dh", "").strip() auth_key = request.form.get("auth", "").strip() if not endpoint or not p256dh or not auth_key: return jsonify({"ok": False, "error": "Unvollständige Push-Daten."}), 400 get_db().execute( """ INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, user_agent, is_active) VALUES (?, ?, ?, ?, ?, 1) ON CONFLICT(endpoint) DO UPDATE SET user_id = excluded.user_id, p256dh = excluded.p256dh, auth = excluded.auth, user_agent = excluded.user_agent, is_active = 1, updated_at = CURRENT_TIMESTAMP """, (g.user["id"], endpoint, p256dh, auth_key, request.headers.get("User-Agent", "")), ) ensure_user_settings_row() get_db().execute( "UPDATE user_settings SET push_enabled = 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?", (g.user["id"],), ) get_db().commit() return jsonify({"ok": True}) @main_bp.post("/push/unsubscribe") @login_required def push_unsubscribe(): endpoint = request.form.get("endpoint", "").strip() if endpoint: get_db().execute( "UPDATE push_subscriptions SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE endpoint = ? AND user_id = ?", (endpoint, g.user["id"]), ) else: get_db().execute( "UPDATE push_subscriptions SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?", (g.user["id"],), ) get_db().commit() return jsonify({"ok": True}) @main_bp.route("/items/") @login_required def item_list(kind: str): if kind not in ITEM_KIND_LABELS: return redirect(url_for("main.dashboard")) query = request.args.get("q", "").strip() state = request.args.get("state", "").strip() scope = request.args.get("visibility", "").strip() raw_daypart_id = request.args.get("daypart_id", "").strip() daypart_id = int(raw_daypart_id) if raw_daypart_id.isdigit() else None 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, ) return render_template( "items/list.html", kind=kind, items=items, availability_labels=AVAILABILITY_LABELS, query=query, selected_state=state, selected_visibility=scope, selected_daypart_id=daypart_id, dayparts=get_dayparts(), state_options=ACTIVE_STATE_OPTIONS, visibility_options=VISIBILITY_FILTER_OPTIONS, today=date.today(), ) @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): if kind not in ITEM_KIND_LABELS: return redirect(url_for("main.dashboard")) form_data = { "name": request.args.get("name", "").strip(), "category": "", "base_type": "neutral", "flavor_profile": "neutral", "suggestion_role": "base", "suggestion_priority": "normal", "can_be_meal_core": False, "meal_type": normalize_meal_type(request.args.get("meal_type"), "snack"), "meal_tags": [], "energy_density": "neutral", "note": "", "visibility": "shared", "target_user_id": None, "target_user_raw": TARGET_USER_OPTIONS_DEFAULT, "food_search": "", "daypart_ids": [], "component_ids": [int(value) for value in request.args.getlist("component_ids") if value.isdigit()], "quick_food_name": "", "quick_food_category": "", "quick_food_base_type": "neutral", "quick_food_flavor_profile": "neutral", "quick_food_role": "base", "quick_food_priority": "normal", "quick_food_can_be_meal_core": False, "quick_food_energy_density": "neutral", "quick_food_note": "", } if request.method == "POST": form_action = request.form.get("form_action", "save_item") form_data = extract_item_form_data(kind, form_data) if kind == "meal" and request.form.get("remove_component_id", "").isdigit(): remove_component_id = int(request.form.get("remove_component_id", "0")) form_data["component_ids"] = [ component_id for component_id in form_data["component_ids"] if component_id != remove_component_id ] return render_item_form(kind, item=None, form_data=form_data) if kind == "meal" and form_action == "filter_foods": return render_item_form(kind, item=None, form_data=form_data) if kind == "meal" and form_action == "quick_add_food": if not form_data["quick_food_name"]: flash("Bitte einen Namen für das neue Lebensmittel eintragen.", "error") else: new_food_id = create_quick_food_from_form(form_data) if new_food_id not in form_data["component_ids"]: form_data["component_ids"].append(new_food_id) form_data["quick_food_name"] = "" form_data["quick_food_category"] = "" form_data["quick_food_note"] = "" flash("Das neue Lebensmittel wurde angelegt und direkt zur Mahlzeitenidee hinzugefügt.", "success") return render_item_form(kind, item=None, form_data=form_data) error = None if not form_data["name"]: error = "Bitte einen Namen eintragen." photo_filename = None if error is None: try: photo_filename = save_photo(request.files.get("photo")) except ValueError as exc: error = str(exc) if error is None: 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, photo_filename, created_by, updated_by ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( current_household_id(), g.user["id"], form_data["target_user_id"], form_data["visibility"], kind, form_data["name"], form_data["category"] if kind == "food" else None, form_data["base_type"] if kind == "food" else "neutral", form_data["flavor_profile"] if kind == "food" else "neutral", form_data["suggestion_role"] if kind == "food" else "base", form_data["suggestion_priority"] if kind == "food" else "normal", 1 if (form_data["can_be_meal_core"] if kind == "food" else False) else 0, form_data["meal_type"] if kind == "meal" else None, encode_tag_list(form_data["meal_tags"]) if kind == "meal" else "", form_data["energy_density"], form_data["note"], photo_filename, g.user["id"], g.user["id"], ), ) item_id = int(cursor.lastrowid) sync_item_dayparts(item_id, form_data["daypart_ids"]) if kind == "meal": 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") return render_item_form(kind, item=None, form_data=form_data) @main_bp.route("/items//edit", methods=("GET", "POST")) @login_required def item_edit(item_id: int): try: item = get_item(item_id) ensure_can_edit(item) except (ValueError, PermissionError) as exc: flash(str(exc), "error") return redirect(url_for("main.dashboard")) form_data = { "name": item["name"], "category": item["category"] or "", "base_type": item.get("base_type") or "neutral", "flavor_profile": item.get("flavor_profile") or "neutral", "suggestion_role": item.get("suggestion_role") or "base", "suggestion_priority": item.get("suggestion_priority") or "normal", "can_be_meal_core": bool(item.get("can_be_meal_core")), "meal_type": item.get("meal_type") or meal_type_for_daypart(item.get("primary_daypart_id")), "meal_tags": normalize_meal_tags(item.get("meal_tags")), "energy_density": item.get("energy_density") or "neutral", "note": item["note"] or "", "visibility": item["visibility"], "target_user_id": item["target_user_id"], "target_user_raw": str(item["target_user_id"]) if item["target_user_id"] else TARGET_USER_OPTIONS_DEFAULT, "food_search": "", "daypart_ids": get_item_daypart_ids(item_id), "component_ids": get_meal_component_ids(item_id) if item["kind"] == "meal" else [], "quick_food_name": "", "quick_food_category": "", "quick_food_base_type": "neutral", "quick_food_flavor_profile": "neutral", "quick_food_role": "base", "quick_food_priority": "normal", "quick_food_can_be_meal_core": False, "quick_food_energy_density": "neutral", "quick_food_note": "", } if request.method == "POST": form_action = request.form.get("form_action", "save_item") form_data = extract_item_form_data(item["kind"], form_data) if item["kind"] == "meal" and request.form.get("remove_component_id", "").isdigit(): remove_component_id = int(request.form.get("remove_component_id", "0")) form_data["component_ids"] = [ component_id for component_id in form_data["component_ids"] if component_id != remove_component_id ] return render_item_form(item["kind"], item=item, form_data=form_data) if item["kind"] == "meal" and form_action == "filter_foods": return render_item_form(item["kind"], item=item, form_data=form_data) if item["kind"] == "meal" and form_action == "quick_add_food": if not form_data["quick_food_name"]: flash("Bitte einen Namen für das neue Lebensmittel eintragen.", "error") else: new_food_id = create_quick_food_from_form(form_data) if new_food_id not in form_data["component_ids"]: form_data["component_ids"].append(new_food_id) form_data["quick_food_name"] = "" form_data["quick_food_category"] = "" form_data["quick_food_note"] = "" flash("Das neue Lebensmittel wurde angelegt und direkt zur Mahlzeitenidee hinzugefügt.", "success") return render_item_form(item["kind"], item=item, form_data=form_data) error = None if not form_data["name"]: error = "Bitte einen Namen eintragen." photo_filename = item["photo_filename"] if error is None: try: photo_filename = save_photo(request.files.get("photo"), current_filename=item["photo_filename"]) except ValueError as exc: error = str(exc) if error is None: get_db().execute( """ UPDATE items SET name = ?, category = ?, base_type = ?, flavor_profile = ?, suggestion_role = ?, suggestion_priority = ?, can_be_meal_core = ?, meal_type = ?, meal_tags = ?, energy_density = ?, note = ?, visibility = ?, target_user_id = ?, photo_filename = ?, updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, ( form_data["name"], form_data["category"] if item["kind"] == "food" else None, form_data["base_type"] if item["kind"] == "food" else "neutral", form_data["flavor_profile"] if item["kind"] == "food" else "neutral", form_data["suggestion_role"] if item["kind"] == "food" else "base", form_data["suggestion_priority"] if item["kind"] == "food" else "normal", 1 if (form_data["can_be_meal_core"] if item["kind"] == "food" else False) else 0, form_data["meal_type"] if item["kind"] == "meal" else None, encode_tag_list(form_data["meal_tags"]) if item["kind"] == "meal" else "", form_data["energy_density"], form_data["note"], form_data["visibility"], form_data["target_user_id"], photo_filename, g.user["id"], item_id, ), ) sync_item_dayparts(item_id, form_data["daypart_ids"]) if item["kind"] == "meal": 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") return render_item_form(item["kind"], item=item, form_data=form_data) @main_bp.post("/items//shopping") @login_required def item_add_to_shopping(item_id: int): try: item = get_item(item_id) except ValueError as exc: 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"], item["visibility"], ) if result["count"]: if result["used_components"]: flash(f"Für „{item['name']}“ wurden fehlende Lebensmittel auf die Einkaufsliste gesetzt.", "success") else: flash(f"{item['name']} steht jetzt auf der Einkaufsliste.", "success") elif result["scheduled_count"]: flash(f"Für „{item['name']}“ sind fehlende Lebensmittel für einen späteren Einkauf vorgemerkt.", "info") else: flash(f"Für „{item['name']}“ ist gerade nichts zusätzlich nötig.", "info") return redirect(request.referrer or url_for("main.shopping_list")) @main_bp.post("/items//set-home") @login_required def item_set_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 = 'home', 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 unter Zuhause sichtbar.", "success") 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): 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.archive_view")) get_db().execute( """ 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() flash(f"{item['name']} liegt jetzt im Archiv und bleibt später leicht wiederfindbar.", "info") return redirect(request.referrer or url_for("main.archive_view")) @main_bp.post("/items//restore") @login_required def item_restore(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.archive_view")) get_db().execute( """ 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() flash(f"{item['name']} ist wieder in der aktiven Liste.", "success") 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), ) if item["kind"] != "shopping": 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("/shopping//note") @login_required def shopping_update_note(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_with_scroll_position(url_for("main.shopping_list"))) try: ensure_can_edit(describe_record(dict(entry)), "Diesen Einkaufseintrag kannst du gerade nicht ändern.") except PermissionError as exc: flash(str(exc), "error") return redirect(url_with_scroll_position(url_for("main.shopping_list"))) shopping_note = normalize_shopping_note(request.form.get("shopping_note")) duplicate = get_db().execute( """ SELECT id FROM shopping_entries WHERE item_id = ? AND shopping_note = ? AND is_checked = 0 AND id != ? LIMIT 1 """, (entry["item_id"], shopping_note, entry_id), ).fetchone() if duplicate: flash("Dieser Hinweis steht für das Lebensmittel schon auf der Einkaufsliste.", "info") return redirect(url_with_scroll_position(url_for("main.shopping_list"))) get_db().execute( "UPDATE shopping_entries SET shopping_note = ? WHERE id = ?", (shopping_note, entry_id), ) get_db().commit() flash("Der Einkaufshinweis wurde gespeichert.", "success") return redirect(url_with_scroll_position(url_for("main.shopping_list"))) @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")) 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")) @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.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 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_item_by_name(item_search) if item is None: flash("Bitte einen Treffer auswählen oder den Begriff als Lebensmittel bzw. Einkaufsartikel anlegen.", "error") else: flash("Bitte zuerst etwas auswählen.", "error") if item is not None: result = ensure_item_or_missing_components_are_shopped( item["id"], g.user["id"], item["visibility"], shopping_note=shopping_note, ) if result["count"]: note_suffix = f" ({shopping_note})" if shopping_note else "" flash(f"Die Einkaufsliste wurde ergänzt: {', '.join(result['names'][:4])}{note_suffix}.", "success") elif result["scheduled_count"]: flash("Ein paar Dinge sind für einen späteren Einkauf vorgemerkt.", "info") else: flash("Dieser Einkaufseintrag steht so schon auf der Liste.", "info") return redirect(url_with_scroll_position(url_for("main.shopping_list"))) entries = fetch_shopping_entries() upcoming_entries = fetch_upcoming_shopping_needs() 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( "shopping/list.html", entries=entries, upcoming_entries=upcoming_entries, addable_items=addable_items, household_settings=household_settings, shopping_weekday_label=shopping_weekday_label, ) @main_bp.post("/shopping//check") @login_required def shopping_check(entry_id: int): try: item = mark_shopping_entry_checked(entry_id) 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"))) @main_bp.post("/shopping//remove") @login_required def shopping_remove(entry_id: int): try: remove_shopping_entry(entry_id) except (ValueError, PermissionError) as exc: flash(str(exc), "error") return redirect(url_with_scroll_position(url_for("main.shopping_list"))) flash("Der Eintrag wurde von der Einkaufsliste entfernt.", "info") return redirect(url_with_scroll_position(url_for("main.shopping_list"))) @main_bp.get("/home") @login_required def home_view(): query = request.args.get("q", "").strip() scope = request.args.get("visibility", "").strip() raw_daypart_id = request.args.get("daypart_id", "").strip() daypart_id = int(raw_daypart_id) if raw_daypart_id.isdigit() else None dayparts = get_dayparts() items = fetch_items( availability="home", query=query or None, daypart_id=daypart_id, visibility=scope or None, ) return render_template( "home/list.html", sections=build_home_sections(items, dayparts, daypart_id), recipe_suggestions=build_home_recipe_suggestions(daypart_id, limit=4), query=query, dayparts=dayparts, selected_daypart_id=daypart_id, selected_visibility=scope, visibility_options=VISIBILITY_FILTER_OPTIONS, today=date.today(), ) @main_bp.get("/archive") @login_required def archive_view(): query = request.args.get("q", "").strip() selected_kind = request.args.get("kind", "").strip() selected_visibility = request.args.get("visibility", "").strip() kind = selected_kind if selected_kind in ITEM_KIND_LABELS else None items = fetch_items( kind=kind, availability="archived", include_archived=True, query=query or None, visibility=selected_visibility or None, ) return render_template( "archive/list.html", items=items, query=query, selected_kind=selected_kind, selected_visibility=selected_visibility, kind_options=KIND_FILTER_OPTIONS, visibility_options=VISIBILITY_FILTER_OPTIONS, today=date.today(), ) @main_bp.get("/planner") @login_required def planner(): week_start = parse_week_start(request.args.get("week")) return render_template( "planner/week.html", week_start=week_start, week_end=week_start + timedelta(days=6), prev_week=week_start - timedelta(days=7), next_week=week_start + timedelta(days=7), week_cards=fetch_week_cards(week_start), today=date.today(), week_templates=fetch_week_templates()[:6], week_hints=build_week_hints(week_start), upcoming_entries=fetch_upcoming_shopping_needs(limit=8), household_settings=get_household_settings(), visibility_options=VISIBILITY_FORM_OPTIONS, ) @main_bp.get("/planner/export.pdf") @login_required def planner_export_pdf(): week_start = parse_week_start(request.args.get("week")) mode = normalize_pdf_export_mode(request.args.get("mode")) try: pdf_bytes = build_week_plan_pdf(week_start, mode=mode) except RuntimeError as exc: flash(str(exc), "error") return redirect(url_for("main.planner", week=week_start.isoformat())) week_number = week_start.isocalendar().week prefix = "mein-essensplan" if mode == "mine" else "unser-essensplan" filename = f"{prefix}-kw-{week_number:02d}-{week_start.year}.pdf" return send_file( BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=filename, ) @main_bp.route("/planner/day", methods=("GET", "POST")) @login_required def planner_day(): selected_date = parse_plan_date(request.values.get("date")) if request.method == "POST": item_id_raw = request.form.get("item_id", "").strip() daypart_id_raw = request.form.get("daypart_id", "").strip() note = request.form.get("note", "").strip() selected_date = parse_plan_date(request.form.get("plan_date")) visibility = normalize_visibility(request.form.get("visibility"), "shared") if not item_id_raw.isdigit(): flash("Bitte etwas für den Tagesplan auswählen.", "error") elif not daypart_id_raw.isdigit(): flash("Bitte eine Tageszeit auswählen.", "error") else: item_id = int(item_id_raw) daypart_id = int(daypart_id_raw) try: get_item(item_id) shopping_result = insert_plan_entry( item_id=item_id, daypart_id=daypart_id, plan_date=selected_date, visibility=visibility, note=note, ) if shopping_result["count"]: flash("Fehlende Lebensmittel wurden für den nächsten Einkauf ergänzt.", "info") elif shopping_result["scheduled_count"]: flash("Fehlende Lebensmittel wurden für einen späteren Einkauf vorgemerkt.", "info") flash("Der Eintrag wurde in den Tagesplan gelegt.", "success") return redirect( f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}" ) except ValueError as exc: flash(str(exc), "error") selected_item_raw = request.args.get("item_id", "").strip() selected_daypart_raw = request.args.get("daypart_id", "").strip() selected_meal_name = request.args.get("meal_name", "").strip() selected_components_raw = request.args.get("component_ids", "").strip() selected_item_id = int(selected_item_raw) if selected_item_raw.isdigit() else None selected_daypart_id = int(selected_daypart_raw) if selected_daypart_raw.isdigit() else None selected_component_ids = [int(value) for value in selected_components_raw.split(",") if value.isdigit()] return render_template( "planner/day.html", selected_date=selected_date, previous_day=selected_date - timedelta(days=1), next_day=selected_date + timedelta(days=1), sections=build_day_planner_sections( selected_date, selected_item_id, selected_daypart_id, selected_meal_name=selected_meal_name, selected_component_ids=selected_component_ids, ), today=date.today(), visibility_options=VISIBILITY_FORM_OPTIONS, day_templates=fetch_day_templates()[:6], day_hints=build_day_hints(selected_date), ) @main_bp.post("/planner/day/generated-meal") @login_required def planner_generated_meal(): selected_date = parse_plan_date(request.form.get("plan_date")) daypart_raw = request.form.get("daypart_id", "").strip() meal_name = request.form.get("meal_name", "").strip() component_ids = [int(value) for value in request.form.getlist("component_ids") if value.isdigit()] visibility = normalize_visibility(request.form.get("visibility"), "shared") if not daypart_raw.isdigit() or not meal_name or not component_ids: flash("Die vorgeschlagene Kombination konnte gerade nicht übernommen werden.", "error") return redirect(url_for("main.planner_day", date=selected_date.isoformat())) daypart_id = int(daypart_raw) meal_id = create_or_get_generated_meal( name=meal_name, component_ids=component_ids, daypart_id=daypart_id, visibility=visibility, ) shopping_result = insert_plan_entry( item_id=meal_id, daypart_id=daypart_id, plan_date=selected_date, visibility=visibility, ) if shopping_result["count"]: flash("Die Kombination wurde eingeplant und fehlende Lebensmittel wurden ergänzt.", "info") elif shopping_result["scheduled_count"]: flash("Die Kombination wurde eingeplant. Fehlende Lebensmittel sind für später vorgemerkt.", "info") flash("Die Kombination wurde als Mahlzeitenidee übernommen.", "success") return redirect(f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}") @main_bp.post("/planner//update") @login_required def planner_update(entry_id: int): selected_date = parse_plan_date(request.form.get("plan_date")) return_week = request.form.get("return_week", "").strip() entry = get_db().execute( f""" SELECT plan_entries.*, owner.display_name AS owner_display_name, owner.username AS owner_username FROM plan_entries LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id WHERE plan_entries.id = ? AND {visible_clause('plan_entries')} """, [entry_id, *visible_params()], ).fetchone() if entry is None: flash("Der Planeintrag wurde nicht gefunden.", "error") return redirect(url_for("main.planner_day", date=selected_date.isoformat())) try: ensure_can_edit(describe_record(dict(entry)), "Diesen Planeintrag kannst du gerade nicht bearbeiten.") except PermissionError as exc: flash(str(exc), "error") if return_week: return redirect(url_for("main.planner", week=return_week)) return redirect(url_for("main.planner_day", date=selected_date.isoformat())) visibility = normalize_visibility(request.form.get("visibility"), entry["visibility"]) note = request.form.get("note", "").strip() update_plan_entry(entry_id, visibility=visibility, note=note) flash("Der Planeintrag wurde angepasst.", "success") if return_week: return redirect(url_for("main.planner", week=return_week)) return redirect(url_for("main.planner_day", date=selected_date.isoformat(), daypart_id=entry["daypart_id"])) @main_bp.post("/planner//remove") @login_required def planner_remove(entry_id: int): selected_date = request.args.get("date", "") or request.form.get("plan_date", "").strip() return_week = request.form.get("return_week", "").strip() entry = get_db().execute( f""" SELECT plan_entries.*, owner.display_name AS owner_display_name, owner.username AS owner_username FROM plan_entries LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id WHERE plan_entries.id = ? AND {visible_clause('plan_entries')} """, [entry_id, *visible_params()], ).fetchone() if entry is None: flash("Der Planeintrag wurde nicht gefunden.", "error") else: try: ensure_can_edit(describe_record(dict(entry)), "Diesen Planeintrag kannst du gerade nicht entfernen.") get_db().execute("DELETE FROM plan_entries WHERE id = ?", (entry_id,)) get_db().commit() flash("Der Planeintrag wurde entfernt.", "info") except PermissionError as exc: flash(str(exc), "error") if return_week: return redirect(url_for("main.planner", week=return_week)) if selected_date: return redirect(url_for("main.planner_day", date=selected_date)) return redirect(url_for("main.planner")) @main_bp.post("/planner//move") @login_required def planner_move(entry_id: int): target_date = parse_plan_date(request.form.get("target_date")) target_daypart_raw = request.form.get("target_daypart_id", "").strip() if not target_daypart_raw.isdigit(): return jsonify({"ok": False, "error": "Ungültige Tageszeit"}), 400 entry = get_db().execute( f""" SELECT plan_entries.*, owner.display_name AS owner_display_name, owner.username AS owner_username FROM plan_entries LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id WHERE plan_entries.id = ? AND {visible_clause('plan_entries')} """, [entry_id, *visible_params()], ).fetchone() if entry is None: return jsonify({"ok": False, "error": "Eintrag nicht gefunden"}), 404 try: ensure_can_edit(describe_record(dict(entry)), "Diesen Planeintrag kannst du gerade nicht verschieben.") except PermissionError as exc: return jsonify({"ok": False, "error": str(exc)}), 403 target_daypart_id = int(target_daypart_raw) get_db().execute( "UPDATE plan_entries SET plan_date = ?, daypart_id = ? WHERE id = ?", (target_date.isoformat(), target_daypart_id, entry_id), ) get_db().commit() shopping_result = ensure_item_or_missing_components_are_shopped( entry["item_id"], g.user["id"], entry["visibility"], needed_for_date=target_date.isoformat(), needed_for_daypart_id=target_daypart_id, source_item_id=entry["item_id"], ) if shopping_result["count"]: flash("Fehlende Lebensmittel wurden nach dem Verschieben ergänzt.", "info") elif shopping_result["scheduled_count"]: flash("Fehlende Lebensmittel sind für den passenden Einkauf vorgemerkt.", "info") return jsonify( { "ok": True, "added_to_shopping": bool(shopping_result["count"]), "redirect_url": url_for("main.planner", week=parse_week_start(target_date.isoformat()).isoformat()), } ) @main_bp.post("/planner/slot/copy-forward") @login_required def planner_slot_copy_forward(): source_date = parse_plan_date(request.form.get("source_date")) target_date = source_date + timedelta(days=1) daypart_raw = request.form.get("daypart_id", "").strip() if not daypart_raw.isdigit(): flash("Die Tageszeit konnte nicht erkannt werden.", "error") return redirect(url_for("main.planner", week=parse_week_start(source_date.isoformat()).isoformat())) daypart_id = int(daypart_raw) entries = get_db().execute( f""" SELECT plan_entries.*, owner.display_name AS owner_display_name, owner.username AS owner_username FROM plan_entries LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id WHERE plan_entries.plan_date = ? AND plan_entries.daypart_id = ? AND {visible_clause('plan_entries')} ORDER BY plan_entries.id """, [source_date.isoformat(), daypart_id, *visible_params()], ).fetchall() copied_count = 0 shopping_added = 0 shopping_scheduled = 0 for raw_entry in entries: entry = describe_record(dict(raw_entry)) try: ensure_can_edit(entry, "Diesen Planeintrag kannst du gerade nicht kopieren.") except PermissionError: continue duplicate = get_db().execute( """ SELECT id FROM plan_entries WHERE household_id = ? AND plan_date = ? AND daypart_id = ? AND item_id = ? AND visibility = ? AND COALESCE(note, '') = COALESCE(?, '') LIMIT 1 """, ( current_household_id(), target_date.isoformat(), daypart_id, entry["item_id"], entry["visibility"], entry.get("note", ""), ), ).fetchone() if duplicate: continue shopping_result = insert_plan_entry( item_id=entry["item_id"], daypart_id=daypart_id, plan_date=target_date, visibility=entry["visibility"], note=entry.get("note", "") or "", ) copied_count += 1 shopping_added += int(shopping_result["count"]) shopping_scheduled += int(shopping_result["scheduled_count"]) if copied_count == 0: flash("Für diese Tageszeit gab es nichts Neues zum Kopieren.", "info") else: if shopping_added: flash("Fehlende Lebensmittel wurden für den passenden Einkauf ergänzt.", "info") elif shopping_scheduled: flash("Fehlende Lebensmittel wurden für später vorgemerkt.", "info") flash(f"{copied_count} Eintrag{' wurde' if copied_count == 1 else 'e wurden'} zum nächsten Tag kopiert.", "success") return redirect(url_for("main.planner", week=parse_week_start(source_date.isoformat()).isoformat()))