release nouri 1.0.0
This commit is contained in:
+179
-9
@@ -27,10 +27,14 @@ from .constants import (
|
||||
DEFAULT_CATEGORY_BUILDERS,
|
||||
DAY_TEMPLATE_NAME_SUGGESTIONS,
|
||||
DEFAULT_CATEGORIES,
|
||||
ENERGY_DENSITY_LABELS,
|
||||
ENERGY_DENSITY_OPTIONS,
|
||||
ITEM_KIND_LABELS,
|
||||
ITEM_KIND_SINGULAR_LABELS,
|
||||
ITEM_SET_NAME_SUGGESTIONS,
|
||||
NOTIFICATION_CHANNEL_OPTIONS,
|
||||
SUGGESTION_STYLE_LABELS,
|
||||
SUGGESTION_STYLE_OPTIONS,
|
||||
VISIBILITY_DESCRIPTIONS,
|
||||
VISIBILITY_LABELS,
|
||||
WEEKDAY_OPTIONS,
|
||||
@@ -190,6 +194,9 @@ def get_user_settings() -> dict:
|
||||
"show_planned_not_shopped",
|
||||
"remind_tomorrow_if_sparse",
|
||||
"remind_week_if_sparse",
|
||||
"push_missing_breakfast",
|
||||
"push_missing_lunch",
|
||||
"push_missing_dinner",
|
||||
"suggest_home_for_today",
|
||||
"remind_small_snack",
|
||||
"remind_nuts",
|
||||
@@ -200,6 +207,8 @@ def get_user_settings() -> dict:
|
||||
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"])
|
||||
return settings
|
||||
|
||||
|
||||
@@ -220,6 +229,24 @@ def normalize_notification_channel(raw: str | None, default: str = "in_app") ->
|
||||
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}
|
||||
if raw == "easy" or raw == "snack":
|
||||
return "balanced"
|
||||
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 suggestion_style_energy_preference(style: str) -> str:
|
||||
if style == "fitness":
|
||||
return "low"
|
||||
return "neutral"
|
||||
|
||||
|
||||
def visible_clause(table_alias: str) -> str:
|
||||
return (
|
||||
f"{table_alias}.household_id = ? "
|
||||
@@ -284,6 +311,8 @@ def describe_record(entry: dict) -> dict:
|
||||
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["is_personal"] = entry.get("visibility") == "personal"
|
||||
entry["is_shared"] = entry.get("visibility") == "shared"
|
||||
entry["is_mine"] = entry.get("owner_user_id") == g.user["id"]
|
||||
@@ -501,6 +530,44 @@ def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]:
|
||||
return builder_map
|
||||
|
||||
|
||||
def score_suggestion_components(component_items: list[dict], daypart_slug: str, settings: dict) -> int:
|
||||
builder_keys = {key for item in component_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 = 0
|
||||
|
||||
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
|
||||
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 "veg" in builder_keys 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,
|
||||
@@ -589,6 +656,7 @@ def extract_item_form_data(existing: dict | None = None) -> dict:
|
||||
{
|
||||
"name": request.form.get("name", "").strip(),
|
||||
"category": request.form.get("category", "").strip(),
|
||||
"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")),
|
||||
@@ -598,6 +666,7 @@ def extract_item_form_data(existing: dict | None = None) -> dict:
|
||||
"component_ids": [int(value) for value in request.form.getlist("component_ids") if value.isdigit()],
|
||||
"quick_food_name": request.form.get("quick_food_name", "").strip(),
|
||||
"quick_food_category": request.form.get("quick_food_category", "").strip(),
|
||||
"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(),
|
||||
}
|
||||
)
|
||||
@@ -608,9 +677,9 @@ 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, note, created_by, updated_by
|
||||
household_id, owner_user_id, target_user_id, visibility, kind, name, category, energy_density, note, created_by, updated_by
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
current_household_id(),
|
||||
@@ -619,6 +688,7 @@ def create_quick_food_from_form(form_data: dict) -> int:
|
||||
form_data["visibility"],
|
||||
form_data["quick_food_name"],
|
||||
form_data["quick_food_category"],
|
||||
form_data["quick_food_energy_density"],
|
||||
form_data["quick_food_note"],
|
||||
g.user["id"],
|
||||
g.user["id"],
|
||||
@@ -1178,11 +1248,13 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li
|
||||
target_patterns = [
|
||||
("carb", "dairy", "fruit"),
|
||||
("carb", "dairy", "nuts"),
|
||||
("carb", "dairy", "seeds"),
|
||||
("carb", "fruit", "dairy"),
|
||||
]
|
||||
reasons = {
|
||||
("carb", "dairy", "fruit"): "Passt gut zu Frühstück oder Snack",
|
||||
("carb", "dairy", "nuts"): "Lässt sich gut für einen Snack vormerken",
|
||||
("carb", "dairy", "seeds"): "Lässt sich gut für einen Snack vormerken",
|
||||
("carb", "fruit", "dairy"): "Zuhause gut kombinierbar",
|
||||
}
|
||||
else:
|
||||
@@ -1222,12 +1294,15 @@ 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 "")
|
||||
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}
|
||||
|
||||
suggestions: list[dict] = []
|
||||
seen_signatures: set[tuple[int, ...]] = set()
|
||||
@@ -1238,26 +1313,37 @@ def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4)
|
||||
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]
|
||||
suggestions.append(
|
||||
{
|
||||
"title": meal["name"],
|
||||
"reason": "Zuhause vorhanden",
|
||||
"component_ids": meal["component_ids"],
|
||||
"existing_item_id": meal["id"],
|
||||
"score": score_suggestion_components(component_items, daypart_slug=daypart_slug, settings=settings) + 40,
|
||||
}
|
||||
)
|
||||
|
||||
daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "")
|
||||
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:
|
||||
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()
|
||||
for suggestion in suggestions:
|
||||
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"])
|
||||
@@ -1287,6 +1373,10 @@ def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | Non
|
||||
item for item in fetch_items(kind="food", availability="home", daypart_id=daypart_id)
|
||||
if first_missing in item.get("builder_keys", [])
|
||||
]
|
||||
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.",
|
||||
@@ -1362,7 +1452,7 @@ def build_dashboard_hints(today: date) -> list[str]:
|
||||
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" in item.get("builder_keys", [])]
|
||||
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("Heute schon an Nüsse gedacht?")
|
||||
|
||||
@@ -1567,7 +1657,52 @@ def dedupe_items(items: list[dict], limit: int = 6) -> list[dict]:
|
||||
return result
|
||||
|
||||
|
||||
def build_day_planner_sections(selected_date: date, selected_item_id: int | None, selected_daypart_id: int | None):
|
||||
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": "Bereit zum 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": "Vorgeschlagen aus dem, was zuhause da ist",
|
||||
"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():
|
||||
@@ -1596,6 +1731,13 @@ def build_day_planner_sections(selected_date: date, selected_item_id: int | None
|
||||
"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"],
|
||||
"summary_items": [entry["item_name"] for entry in entries][:2],
|
||||
"default_visibility": "shared",
|
||||
@@ -2082,6 +2224,7 @@ def render_item_form(kind: str, *, item: dict | None, form_data: dict):
|
||||
form_data.get("category") or form_data.get("quick_food_category")
|
||||
),
|
||||
form_data=form_data,
|
||||
energy_density_options=ENERGY_DENSITY_OPTIONS,
|
||||
visibility_options=VISIBILITY_FORM_OPTIONS,
|
||||
target_user_options=get_target_user_options(),
|
||||
)
|
||||
@@ -2588,18 +2731,24 @@ def settings_view():
|
||||
flash("Die Einkaufsrhythmus-Einstellungen wurden gespeichert.", "success")
|
||||
elif form_name == "reminders":
|
||||
ensure_user_settings_row()
|
||||
suggestion_style = normalize_suggestion_style(request.form.get("suggestion_style"), "balanced")
|
||||
get_db().execute(
|
||||
"""
|
||||
UPDATE user_settings
|
||||
SET reminders_enabled = ?,
|
||||
push_enabled = ?,
|
||||
notification_channel = ?,
|
||||
suggestion_style = ?,
|
||||
energy_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 = ?,
|
||||
suggest_home_for_today = ?,
|
||||
remind_small_snack = ?,
|
||||
remind_nuts = ?,
|
||||
@@ -2613,12 +2762,17 @@ def settings_view():
|
||||
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),
|
||||
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("suggest_home_for_today", True),
|
||||
parse_checkbox("remind_small_snack", False),
|
||||
parse_checkbox("remind_nuts", False),
|
||||
@@ -2831,6 +2985,7 @@ def item_create(kind: str):
|
||||
form_data = {
|
||||
"name": request.args.get("name", "").strip(),
|
||||
"category": "",
|
||||
"energy_density": "neutral",
|
||||
"note": "",
|
||||
"visibility": "shared",
|
||||
"target_user_id": None,
|
||||
@@ -2840,6 +2995,7 @@ def item_create(kind: str):
|
||||
"component_ids": [int(value) for value in request.args.getlist("component_ids") if value.isdigit()],
|
||||
"quick_food_name": "",
|
||||
"quick_food_category": "",
|
||||
"quick_food_energy_density": "neutral",
|
||||
"quick_food_note": "",
|
||||
}
|
||||
|
||||
@@ -2878,9 +3034,9 @@ def item_create(kind: str):
|
||||
cursor = get_db().execute(
|
||||
"""
|
||||
INSERT INTO items (
|
||||
household_id, owner_user_id, target_user_id, visibility, kind, name, category, note, photo_filename, created_by, updated_by
|
||||
household_id, owner_user_id, target_user_id, visibility, kind, name, category, energy_density, note, photo_filename, created_by, updated_by
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
current_household_id(),
|
||||
@@ -2890,6 +3046,7 @@ def item_create(kind: str):
|
||||
kind,
|
||||
form_data["name"],
|
||||
form_data["category"],
|
||||
form_data["energy_density"],
|
||||
form_data["note"],
|
||||
photo_filename,
|
||||
g.user["id"],
|
||||
@@ -2921,6 +3078,7 @@ def item_edit(item_id: int):
|
||||
form_data = {
|
||||
"name": item["name"],
|
||||
"category": item["category"] or "",
|
||||
"energy_density": item.get("energy_density") or "neutral",
|
||||
"note": item["note"] or "",
|
||||
"visibility": item["visibility"],
|
||||
"target_user_id": item["target_user_id"],
|
||||
@@ -2930,6 +3088,7 @@ def item_edit(item_id: int):
|
||||
"component_ids": get_meal_component_ids(item_id) if item["kind"] == "meal" else [],
|
||||
"quick_food_name": "",
|
||||
"quick_food_category": "",
|
||||
"quick_food_energy_density": "neutral",
|
||||
"quick_food_note": "",
|
||||
}
|
||||
|
||||
@@ -2970,6 +3129,7 @@ def item_edit(item_id: int):
|
||||
UPDATE items
|
||||
SET name = ?,
|
||||
category = ?,
|
||||
energy_density = ?,
|
||||
note = ?,
|
||||
visibility = ?,
|
||||
target_user_id = ?,
|
||||
@@ -2981,6 +3141,7 @@ def item_edit(item_id: int):
|
||||
(
|
||||
form_data["name"],
|
||||
form_data["category"],
|
||||
form_data["energy_density"],
|
||||
form_data["note"],
|
||||
form_data["visibility"],
|
||||
form_data["target_user_id"],
|
||||
@@ -3303,14 +3464,23 @@ def planner_day():
|
||||
|
||||
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),
|
||||
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],
|
||||
|
||||
Reference in New Issue
Block a user