Refine meal suggestions and planner entry controls

This commit is contained in:
2026-04-13 12:00:40 +02:00
parent 40bab48806
commit 1490fc8f1d
8 changed files with 334 additions and 35 deletions
+160 -9
View File
@@ -357,7 +357,12 @@ def describe_record(entry: dict) -> dict:
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}"
entry["for_label"] = f"Für {target_name}" if target_name else "Für alle"
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["can_edit"] = entry["is_shared"] or entry["is_mine"] or g.user["role"] == "admin"
return entry
@@ -1267,6 +1272,23 @@ 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:
@@ -1341,6 +1363,13 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li
"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]),
}
)
if len(suggestions) >= limit * 3:
@@ -1353,6 +1382,7 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li
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")
@@ -1360,30 +1390,76 @@ def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4)
]
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 meal["component_ids"] and all(component_id in home_food_ids for component_id in meal["component_ids"]):
signature = normalized_component_signature(meal["component_ids"])
if signature in seen_signatures:
continue
seen_signatures.add(signature)
component_items = [home_food_map[component_id] for component_id in meal["component_ids"] if component_id in home_food_map]
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]
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": meal["component_ids"],
"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:
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]
@@ -1460,6 +1536,13 @@ def build_daypart_suggestions(daypart_id: int) -> list[dict]:
"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]
]
@@ -2397,6 +2480,23 @@ def insert_plan_entry(*, item_id: int, daypart_id: int, plan_date: date, visibil
)
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()
@@ -2457,6 +2557,25 @@ def template_library():
)
@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():
@@ -3580,6 +3699,38 @@ def planner_generated_meal():
return redirect(f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}")
@main_bp.post("/planner/<int:entry_id>/update")
@login_required
def planner_update(entry_id: int):
selected_date = parse_plan_date(request.form.get("plan_date"))
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")
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")
return redirect(url_for("main.planner_day", date=selected_date.isoformat(), daypart_id=entry["daypart_id"]))
@main_bp.post("/planner/<int:entry_id>/remove")
@login_required
def planner_remove(entry_id: int):