Restructure food suggestion data and builder logic
This commit is contained in:
@@ -19,10 +19,21 @@ from .constants import (
|
|||||||
DEFAULT_CATEGORIES,
|
DEFAULT_CATEGORIES,
|
||||||
ENERGY_DENSITY_LABELS,
|
ENERGY_DENSITY_LABELS,
|
||||||
ENERGY_DENSITY_OPTIONS,
|
ENERGY_DENSITY_OPTIONS,
|
||||||
|
FOOD_ROLE_DESCRIPTIONS,
|
||||||
|
FOOD_ROLE_LABELS,
|
||||||
|
FOOD_ROLE_OPTIONS,
|
||||||
ITEM_KIND_LABELS,
|
ITEM_KIND_LABELS,
|
||||||
ITEM_KIND_SINGULAR_LABELS,
|
ITEM_KIND_SINGULAR_LABELS,
|
||||||
|
MEAL_STYLE_LABELS,
|
||||||
|
MEAL_STYLE_OPTIONS,
|
||||||
|
MEAL_TYPE_LABELS,
|
||||||
|
MEAL_TYPE_OPTIONS,
|
||||||
NOTIFICATION_CHANNEL_OPTIONS,
|
NOTIFICATION_CHANNEL_OPTIONS,
|
||||||
|
PROTEIN_PREFERENCE_LABELS,
|
||||||
|
PROTEIN_PREFERENCE_OPTIONS,
|
||||||
ROLE_LABELS,
|
ROLE_LABELS,
|
||||||
|
SUGGESTION_PRIORITY_LABELS,
|
||||||
|
SUGGESTION_PRIORITY_OPTIONS,
|
||||||
SUGGESTION_STYLE_LABELS,
|
SUGGESTION_STYLE_LABELS,
|
||||||
SUGGESTION_STYLE_OPTIONS,
|
SUGGESTION_STYLE_OPTIONS,
|
||||||
VISIBILITY_DESCRIPTIONS,
|
VISIBILITY_DESCRIPTIONS,
|
||||||
@@ -140,11 +151,22 @@ def create_app() -> Flask:
|
|||||||
"builder_labels": BUILDER_LABELS,
|
"builder_labels": BUILDER_LABELS,
|
||||||
"builder_descriptions": BUILDER_DESCRIPTIONS,
|
"builder_descriptions": BUILDER_DESCRIPTIONS,
|
||||||
"builder_options": BUILDER_OPTIONS,
|
"builder_options": BUILDER_OPTIONS,
|
||||||
|
"food_role_labels": FOOD_ROLE_LABELS,
|
||||||
|
"food_role_descriptions": FOOD_ROLE_DESCRIPTIONS,
|
||||||
|
"food_role_options": FOOD_ROLE_OPTIONS,
|
||||||
|
"suggestion_priority_labels": SUGGESTION_PRIORITY_LABELS,
|
||||||
|
"suggestion_priority_options": SUGGESTION_PRIORITY_OPTIONS,
|
||||||
"daypart_suggestions": DAYPARTS,
|
"daypart_suggestions": DAYPARTS,
|
||||||
"energy_density_options": ENERGY_DENSITY_OPTIONS,
|
"energy_density_options": ENERGY_DENSITY_OPTIONS,
|
||||||
"energy_density_labels": ENERGY_DENSITY_LABELS,
|
"energy_density_labels": ENERGY_DENSITY_LABELS,
|
||||||
|
"meal_type_options": MEAL_TYPE_OPTIONS,
|
||||||
|
"meal_type_labels": MEAL_TYPE_LABELS,
|
||||||
|
"meal_style_options": MEAL_STYLE_OPTIONS,
|
||||||
|
"meal_style_labels": MEAL_STYLE_LABELS,
|
||||||
"suggestion_style_options": SUGGESTION_STYLE_OPTIONS,
|
"suggestion_style_options": SUGGESTION_STYLE_OPTIONS,
|
||||||
"suggestion_style_labels": SUGGESTION_STYLE_LABELS,
|
"suggestion_style_labels": SUGGESTION_STYLE_LABELS,
|
||||||
|
"protein_preference_options": PROTEIN_PREFERENCE_OPTIONS,
|
||||||
|
"protein_preference_labels": PROTEIN_PREFERENCE_LABELS,
|
||||||
"visibility_labels": VISIBILITY_LABELS,
|
"visibility_labels": VISIBILITY_LABELS,
|
||||||
"visibility_descriptions": VISIBILITY_DESCRIPTIONS,
|
"visibility_descriptions": VISIBILITY_DESCRIPTIONS,
|
||||||
"role_labels": ROLE_LABELS,
|
"role_labels": ROLE_LABELS,
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ DAYPARTS = [
|
|||||||
{"slug": "late-snack", "name": "Später Snack", "sort_order": 60},
|
{"slug": "late-snack", "name": "Später Snack", "sort_order": 60},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
DAYPART_SLUG_TO_MEAL_TYPE = {
|
||||||
|
"breakfast": "breakfast",
|
||||||
|
"morning-snack": "snack",
|
||||||
|
"lunch": "lunch",
|
||||||
|
"afternoon-snack": "snack",
|
||||||
|
"dinner": "dinner",
|
||||||
|
"late-snack": "snack",
|
||||||
|
}
|
||||||
|
|
||||||
DEFAULT_CATEGORIES = [
|
DEFAULT_CATEGORIES = [
|
||||||
"Kohlenhydrate",
|
"Kohlenhydrate",
|
||||||
"Milchprodukt",
|
"Milchprodukt",
|
||||||
@@ -58,6 +67,42 @@ BUILDER_DESCRIPTIONS = {
|
|||||||
|
|
||||||
BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()]
|
BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()]
|
||||||
|
|
||||||
|
FOOD_ROLE_LABELS = {
|
||||||
|
"main": "Hauptbaustein",
|
||||||
|
"base": "Basis",
|
||||||
|
"complement": "Ergänzung",
|
||||||
|
"topping": "Topping",
|
||||||
|
"cooking": "Kochzutat",
|
||||||
|
"snack": "Snack-Baustein",
|
||||||
|
"solo": "Schnelle Einzelmahlzeit",
|
||||||
|
}
|
||||||
|
|
||||||
|
FOOD_ROLE_DESCRIPTIONS = {
|
||||||
|
"main": "Kann einen Teller oder eine Hauptmahlzeit deutlich tragen.",
|
||||||
|
"base": "Passt gut als Grundlage und lässt sich ruhig ergänzen.",
|
||||||
|
"complement": "Hilft beim Ergänzen, steht aber selten für sich allein.",
|
||||||
|
"topping": "Passt eher oben drauf oder als kleines Extra.",
|
||||||
|
"cooking": "Hilft beim Kochen oder Abschmecken, ist aber selten selbst die Mahlzeit.",
|
||||||
|
"snack": "Passt gut für kleine Zwischenmahlzeiten oder als ruhige Ergänzung.",
|
||||||
|
"solo": "Kann auch alleine als schnelle, einfache Mahlzeit funktionieren.",
|
||||||
|
}
|
||||||
|
|
||||||
|
FOOD_ROLE_OPTIONS = [(key, label) for key, label in FOOD_ROLE_LABELS.items()]
|
||||||
|
|
||||||
|
SUGGESTION_PRIORITY_OPTIONS = [
|
||||||
|
("prefer", "Gern vorschlagen"),
|
||||||
|
("normal", "Normal vorschlagen"),
|
||||||
|
("rare", "Eher selten automatisch vorschlagen"),
|
||||||
|
("never", "Nie automatisch vorschlagen"),
|
||||||
|
]
|
||||||
|
|
||||||
|
SUGGESTION_PRIORITY_LABELS = {
|
||||||
|
"prefer": "Gern vorschlagen",
|
||||||
|
"normal": "Normal vorschlagen",
|
||||||
|
"rare": "Eher selten automatisch vorschlagen",
|
||||||
|
"never": "Nie automatisch vorschlagen",
|
||||||
|
}
|
||||||
|
|
||||||
ENERGY_DENSITY_OPTIONS = [
|
ENERGY_DENSITY_OPTIONS = [
|
||||||
("low", "Eher leicht"),
|
("low", "Eher leicht"),
|
||||||
("neutral", "Neutral"),
|
("neutral", "Neutral"),
|
||||||
@@ -74,12 +119,66 @@ SUGGESTION_STYLE_OPTIONS = [
|
|||||||
("balanced", "Eher ausgewogen"),
|
("balanced", "Eher ausgewogen"),
|
||||||
("fitness", "Fitness"),
|
("fitness", "Fitness"),
|
||||||
("protein", "Proteinbetont"),
|
("protein", "Proteinbetont"),
|
||||||
|
("easy", "Leicht und einfach"),
|
||||||
|
("snack", "Snackorientiert"),
|
||||||
]
|
]
|
||||||
|
|
||||||
SUGGESTION_STYLE_LABELS = {
|
SUGGESTION_STYLE_LABELS = {
|
||||||
"balanced": "Eher ausgewogen",
|
"balanced": "Eher ausgewogen",
|
||||||
"fitness": "Fitness",
|
"fitness": "Fitness",
|
||||||
"protein": "Proteinbetont",
|
"protein": "Proteinbetont",
|
||||||
|
"easy": "Leicht und einfach",
|
||||||
|
"snack": "Snackorientiert",
|
||||||
|
}
|
||||||
|
|
||||||
|
PROTEIN_PREFERENCE_OPTIONS = [
|
||||||
|
("mixed", "Offen gemischt"),
|
||||||
|
("veg-friendly", "Überwiegend vegetarisch"),
|
||||||
|
("rare-animal", "Fleisch und Fisch nur selten"),
|
||||||
|
("plant-forward", "Möglichst pflanzlich"),
|
||||||
|
]
|
||||||
|
|
||||||
|
PROTEIN_PREFERENCE_LABELS = {
|
||||||
|
"mixed": "Offen gemischt",
|
||||||
|
"veg-friendly": "Überwiegend vegetarisch",
|
||||||
|
"rare-animal": "Fleisch und Fisch nur selten",
|
||||||
|
"plant-forward": "Möglichst pflanzlich",
|
||||||
|
}
|
||||||
|
|
||||||
|
MEAL_TYPE_OPTIONS = [
|
||||||
|
("breakfast", "Frühstück"),
|
||||||
|
("lunch", "Mittagessen"),
|
||||||
|
("dinner", "Abendessen"),
|
||||||
|
("snack", "Snack"),
|
||||||
|
]
|
||||||
|
|
||||||
|
MEAL_TYPE_LABELS = {
|
||||||
|
"breakfast": "Frühstück",
|
||||||
|
"lunch": "Mittagessen",
|
||||||
|
"dinner": "Abendessen",
|
||||||
|
"snack": "Snack",
|
||||||
|
}
|
||||||
|
|
||||||
|
MEAL_STYLE_OPTIONS = [
|
||||||
|
("sweet", "Süß"),
|
||||||
|
("savory", "Herzhaft"),
|
||||||
|
("warm", "Warm"),
|
||||||
|
("cold", "Kalt"),
|
||||||
|
("quick", "Schnell"),
|
||||||
|
("simple", "Ruhig und einfach"),
|
||||||
|
("prep", "Gut vorbereitbar"),
|
||||||
|
("portable", "Für unterwegs"),
|
||||||
|
]
|
||||||
|
|
||||||
|
MEAL_STYLE_LABELS = {
|
||||||
|
"sweet": "Süß",
|
||||||
|
"savory": "Herzhaft",
|
||||||
|
"warm": "Warm",
|
||||||
|
"cold": "Kalt",
|
||||||
|
"quick": "Schnell",
|
||||||
|
"simple": "Ruhig und einfach",
|
||||||
|
"prep": "Gut vorbereitbar",
|
||||||
|
"portable": "Für unterwegs",
|
||||||
}
|
}
|
||||||
|
|
||||||
WEEKDAY_OPTIONS = [
|
WEEKDAY_OPTIONS = [
|
||||||
|
|||||||
+284
-1
@@ -8,10 +8,272 @@ from flask import Flask, current_app, g
|
|||||||
from flask.cli import with_appcontext
|
from flask.cli import with_appcontext
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS
|
from .constants import (
|
||||||
|
DAYPARTS,
|
||||||
|
DAYPART_SLUG_TO_MEAL_TYPE,
|
||||||
|
DEFAULT_CATEGORIES,
|
||||||
|
DEFAULT_CATEGORY_BUILDERS,
|
||||||
|
)
|
||||||
|
|
||||||
CURRENT_SCHEMA_VERSION = "1.2.1"
|
CURRENT_SCHEMA_VERSION = "1.2.1"
|
||||||
|
|
||||||
|
ANIMAL_HINTS = (
|
||||||
|
"huhn",
|
||||||
|
"hähn",
|
||||||
|
"rind",
|
||||||
|
"hack",
|
||||||
|
"schwein",
|
||||||
|
"speck",
|
||||||
|
"salami",
|
||||||
|
"wurst",
|
||||||
|
"thunfisch",
|
||||||
|
"lachs",
|
||||||
|
"fisch",
|
||||||
|
"garnelen",
|
||||||
|
"shrimp",
|
||||||
|
"sardinen",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_name_for_profile(name: str | None) -> str:
|
||||||
|
return (name or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def infer_food_profile(name: str | None, category: str | None, energy_density: str | None) -> dict[str, object]:
|
||||||
|
normalized_name = normalize_name_for_profile(name)
|
||||||
|
normalized_category = (category or "").strip().lower()
|
||||||
|
|
||||||
|
base_type = "neutral"
|
||||||
|
if "eiweiß" in normalized_category or "protein" in normalized_category:
|
||||||
|
base_type = "protein"
|
||||||
|
elif "kohlenhyd" in normalized_category or "brot" in normalized_category or "getreide" in normalized_category:
|
||||||
|
base_type = "carb"
|
||||||
|
elif "milch" in normalized_category:
|
||||||
|
base_type = "dairy"
|
||||||
|
elif "obst" in normalized_category:
|
||||||
|
base_type = "fruit"
|
||||||
|
elif "gemüse" in normalized_category:
|
||||||
|
base_type = "veg"
|
||||||
|
elif "nüsse" in normalized_name or "nuss" in normalized_name:
|
||||||
|
base_type = "nuts"
|
||||||
|
elif "saat" in normalized_name or "leinsamen" in normalized_name or "chia" in normalized_name:
|
||||||
|
base_type = "seeds"
|
||||||
|
|
||||||
|
suggestion_role = "base"
|
||||||
|
suggestion_priority = "normal"
|
||||||
|
can_be_meal_core = 0
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("proteinpulver", "eiweißpulver", "whey", "clear whey")):
|
||||||
|
return {
|
||||||
|
"base_type": "protein",
|
||||||
|
"suggestion_role": "complement",
|
||||||
|
"suggestion_priority": "rare",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("flohsamen", "flohsamenschalen", "leinsamen", "chia", "hanfsamen")):
|
||||||
|
return {
|
||||||
|
"base_type": "seeds",
|
||||||
|
"suggestion_role": "topping",
|
||||||
|
"suggestion_priority": "normal",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if "tomatenmark" in normalized_name:
|
||||||
|
return {
|
||||||
|
"base_type": "neutral",
|
||||||
|
"suggestion_role": "cooking",
|
||||||
|
"suggestion_priority": "rare",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("saure gurken", "essiggurken", "cornichons", "gurkenscheiben")):
|
||||||
|
return {
|
||||||
|
"base_type": "veg",
|
||||||
|
"suggestion_role": "complement",
|
||||||
|
"suggestion_priority": "rare",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("tofu", "tempeh", "vegane schnitzel", "vegane nuggets", "veganes hack", "sojageschnetzeltes")):
|
||||||
|
return {
|
||||||
|
"base_type": "protein",
|
||||||
|
"suggestion_role": "main",
|
||||||
|
"suggestion_priority": "prefer",
|
||||||
|
"can_be_meal_core": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("thunfisch", "lachs", "fisch", "huhn", "hähn", "rind", "schwein", "hack")):
|
||||||
|
return {
|
||||||
|
"base_type": "protein",
|
||||||
|
"suggestion_role": "main",
|
||||||
|
"suggestion_priority": "rare",
|
||||||
|
"can_be_meal_core": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("joghurt", "skyr", "quark", "hüttenkäse", "körniger frischkäse")):
|
||||||
|
return {
|
||||||
|
"base_type": "dairy",
|
||||||
|
"suggestion_role": "base",
|
||||||
|
"suggestion_priority": "prefer",
|
||||||
|
"can_be_meal_core": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("müsli", "hafer", "porridge", "cornflakes", "brot", "brötchen", "reis", "nudel", "kartoffel", "wrap")):
|
||||||
|
return {
|
||||||
|
"base_type": "carb",
|
||||||
|
"suggestion_role": "base",
|
||||||
|
"suggestion_priority": "normal",
|
||||||
|
"can_be_meal_core": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("beeren", "banane", "apfel", "obst", "birne", "trauben", "kiwi")):
|
||||||
|
return {
|
||||||
|
"base_type": "fruit",
|
||||||
|
"suggestion_role": "complement",
|
||||||
|
"suggestion_priority": "prefer",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("gemüse", "brokkoli", "spinat", "erbsen", "paprika", "karotte", "zucchini", "salat", "tomate", "tk gemüse")):
|
||||||
|
return {
|
||||||
|
"base_type": "veg",
|
||||||
|
"suggestion_role": "complement",
|
||||||
|
"suggestion_priority": "prefer",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("nussmus", "erdnuss", "mandeln", "walnüsse", "cashew")):
|
||||||
|
return {
|
||||||
|
"base_type": "nuts",
|
||||||
|
"suggestion_role": "topping",
|
||||||
|
"suggestion_priority": "normal",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("terrine", "5-minuten", "instant", "cup noodles")):
|
||||||
|
return {
|
||||||
|
"base_type": "carb" if (energy_density or "neutral") != "high" else "neutral",
|
||||||
|
"suggestion_role": "solo",
|
||||||
|
"suggestion_priority": "rare",
|
||||||
|
"can_be_meal_core": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if base_type in {"protein", "carb", "dairy"}:
|
||||||
|
suggestion_role = "base"
|
||||||
|
can_be_meal_core = 1
|
||||||
|
elif base_type in {"veg", "fruit"}:
|
||||||
|
suggestion_role = "complement"
|
||||||
|
elif base_type in {"nuts", "seeds"}:
|
||||||
|
suggestion_role = "topping"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"base_type": base_type,
|
||||||
|
"suggestion_role": suggestion_role,
|
||||||
|
"suggestion_priority": suggestion_priority,
|
||||||
|
"can_be_meal_core": can_be_meal_core,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def infer_meal_tags(name: str | None, legacy_category: str | None) -> str:
|
||||||
|
normalized_name = normalize_name_for_profile(name)
|
||||||
|
normalized_category = (legacy_category or "").strip().lower()
|
||||||
|
tags: list[str] = []
|
||||||
|
|
||||||
|
if normalized_category == "warmes":
|
||||||
|
tags.extend(["warm", "savory"])
|
||||||
|
if normalized_category == "kleines essen":
|
||||||
|
tags.extend(["simple", "quick"])
|
||||||
|
if normalized_category == "snack":
|
||||||
|
tags.append("simple")
|
||||||
|
if any(token in normalized_name for token in ("porridge", "müsli", "joghurt", "quark")):
|
||||||
|
tags.append("sweet")
|
||||||
|
if any(token in normalized_name for token in ("salat", "brot", "toast", "tofu", "reis", "nudel", "pfanne")):
|
||||||
|
tags.append("savory")
|
||||||
|
if any(token in normalized_name for token in ("to go", "unterwegs", "wrap")):
|
||||||
|
tags.append("portable")
|
||||||
|
if any(token in normalized_name for token in ("overnight", "vorbereitet", "meal prep")):
|
||||||
|
tags.append("prep")
|
||||||
|
if any(token in normalized_name for token in ("schnell", "5-minuten", "instant")):
|
||||||
|
tags.append("quick")
|
||||||
|
if any(token in normalized_name for token in ("einfach", "ruhig")):
|
||||||
|
tags.append("simple")
|
||||||
|
|
||||||
|
unique_tags: list[str] = []
|
||||||
|
for tag in tags:
|
||||||
|
if tag and tag not in unique_tags:
|
||||||
|
unique_tags.append(tag)
|
||||||
|
return ",".join(unique_tags)
|
||||||
|
|
||||||
|
|
||||||
|
def infer_meal_type_from_dayparts(database: sqlite3.Connection, item_id: int) -> str:
|
||||||
|
row = database.execute(
|
||||||
|
"""
|
||||||
|
SELECT dayparts.slug
|
||||||
|
FROM item_dayparts
|
||||||
|
JOIN dayparts ON dayparts.id = item_dayparts.daypart_id
|
||||||
|
WHERE item_dayparts.item_id = ?
|
||||||
|
ORDER BY dayparts.sort_order
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(item_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return "snack"
|
||||||
|
return DAYPART_SLUG_TO_MEAL_TYPE.get(row["slug"], "snack")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_item_profiles(database: sqlite3.Connection) -> None:
|
||||||
|
rows = database.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, kind, name, category, energy_density
|
||||||
|
FROM items
|
||||||
|
ORDER BY id
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
item_id = int(row["id"])
|
||||||
|
if row["kind"] == "food":
|
||||||
|
profile = infer_food_profile(row["name"], row["category"], row["energy_density"])
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE items
|
||||||
|
SET base_type = ?,
|
||||||
|
suggestion_role = ?,
|
||||||
|
suggestion_priority = ?,
|
||||||
|
can_be_meal_core = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
profile["base_type"],
|
||||||
|
profile["suggestion_role"],
|
||||||
|
profile["suggestion_priority"],
|
||||||
|
profile["can_be_meal_core"],
|
||||||
|
item_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
meal_type = infer_meal_type_from_dayparts(database, item_id)
|
||||||
|
meal_tags = infer_meal_tags(row["name"], row["category"])
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE items
|
||||||
|
SET meal_type = COALESCE(NULLIF(meal_type, ''), ?),
|
||||||
|
meal_tags = CASE
|
||||||
|
WHEN meal_tags IS NULL OR meal_tags = '' THEN ?
|
||||||
|
ELSE meal_tags
|
||||||
|
END,
|
||||||
|
category = CASE
|
||||||
|
WHEN kind = 'meal' AND category IN ('Kohlenhydrate', 'Milchprodukt', 'Obst', 'Gemüse', 'Eiweißquelle', 'Snack', 'Warmes', 'Kleines Essen')
|
||||||
|
THEN NULL
|
||||||
|
ELSE category
|
||||||
|
END
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(meal_type, meal_tags, item_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> sqlite3.Connection:
|
def get_db() -> sqlite3.Connection:
|
||||||
if "db" not in g:
|
if "db" not in g:
|
||||||
@@ -129,6 +391,12 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
|||||||
add_column_if_missing(database, "items", "owner_user_id INTEGER")
|
add_column_if_missing(database, "items", "owner_user_id INTEGER")
|
||||||
add_column_if_missing(database, "items", "target_user_id INTEGER")
|
add_column_if_missing(database, "items", "target_user_id INTEGER")
|
||||||
add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'")
|
add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'")
|
||||||
|
add_column_if_missing(database, "items", "base_type TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "items", "suggestion_role TEXT NOT NULL DEFAULT 'base'")
|
||||||
|
add_column_if_missing(database, "items", "suggestion_priority TEXT NOT NULL DEFAULT 'normal'")
|
||||||
|
add_column_if_missing(database, "items", "can_be_meal_core INTEGER NOT NULL DEFAULT 0")
|
||||||
|
add_column_if_missing(database, "items", "meal_type TEXT")
|
||||||
|
add_column_if_missing(database, "items", "meal_tags TEXT NOT NULL DEFAULT ''")
|
||||||
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
|
||||||
if table_exists(database, "shopping_entries"):
|
if table_exists(database, "shopping_entries"):
|
||||||
@@ -254,6 +522,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
|||||||
if table_exists(database, "user_settings"):
|
if table_exists(database, "user_settings"):
|
||||||
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
|
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
|
||||||
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
|
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "user_settings", "protein_preference TEXT NOT NULL DEFAULT 'mixed'")
|
||||||
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
|
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
|
||||||
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
|
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
|
||||||
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
|
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
|
||||||
@@ -375,11 +644,18 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
add_column_if_missing(database, table_name, "owner_user_id INTEGER")
|
add_column_if_missing(database, table_name, "owner_user_id INTEGER")
|
||||||
add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'")
|
add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'")
|
||||||
add_column_if_missing(database, "items", "target_user_id INTEGER")
|
add_column_if_missing(database, "items", "target_user_id INTEGER")
|
||||||
|
add_column_if_missing(database, "items", "base_type TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "items", "suggestion_role TEXT NOT NULL DEFAULT 'base'")
|
||||||
|
add_column_if_missing(database, "items", "suggestion_priority TEXT NOT NULL DEFAULT 'normal'")
|
||||||
|
add_column_if_missing(database, "items", "can_be_meal_core INTEGER NOT NULL DEFAULT 0")
|
||||||
|
add_column_if_missing(database, "items", "meal_type TEXT")
|
||||||
|
add_column_if_missing(database, "items", "meal_tags TEXT NOT NULL DEFAULT ''")
|
||||||
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
|
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
|
||||||
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
|
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
|
||||||
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
|
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
|
||||||
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
|
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "user_settings", "protein_preference TEXT NOT NULL DEFAULT 'mixed'")
|
||||||
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
|
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
|
||||||
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
|
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
|
||||||
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
|
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
|
||||||
@@ -422,6 +698,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
database.execute("UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''")
|
database.execute("UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''")
|
||||||
|
|
||||||
sync_default_categories(database)
|
sync_default_categories(database)
|
||||||
|
migrate_item_profiles(database)
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO user_settings (user_id)
|
INSERT OR IGNORE INTO user_settings (user_id)
|
||||||
@@ -429,8 +706,14 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
database.execute("UPDATE items SET energy_density = 'neutral' WHERE energy_density IS NULL OR energy_density = ''")
|
database.execute("UPDATE items SET energy_density = 'neutral' WHERE energy_density IS NULL OR energy_density = ''")
|
||||||
|
database.execute("UPDATE items SET base_type = 'neutral' WHERE base_type IS NULL OR base_type = ''")
|
||||||
|
database.execute("UPDATE items SET suggestion_role = 'base' WHERE suggestion_role IS NULL OR suggestion_role = ''")
|
||||||
|
database.execute("UPDATE items SET suggestion_priority = 'normal' WHERE suggestion_priority IS NULL OR suggestion_priority = ''")
|
||||||
|
database.execute("UPDATE items SET can_be_meal_core = 0 WHERE can_be_meal_core IS NULL")
|
||||||
|
database.execute("UPDATE items SET meal_tags = '' WHERE meal_tags IS NULL")
|
||||||
database.execute("UPDATE user_settings SET suggestion_style = 'balanced' WHERE suggestion_style IS NULL OR suggestion_style = ''")
|
database.execute("UPDATE user_settings SET suggestion_style = 'balanced' WHERE suggestion_style IS NULL OR suggestion_style = ''")
|
||||||
database.execute("UPDATE user_settings SET energy_preference = 'neutral' WHERE energy_preference IS NULL OR energy_preference = ''")
|
database.execute("UPDATE user_settings SET energy_preference = 'neutral' WHERE energy_preference IS NULL OR energy_preference = ''")
|
||||||
|
database.execute("UPDATE user_settings SET protein_preference = 'mixed' WHERE protein_preference IS NULL OR protein_preference = ''")
|
||||||
database.execute("UPDATE user_settings SET push_missing_breakfast = 0 WHERE push_missing_breakfast IS NULL")
|
database.execute("UPDATE user_settings SET push_missing_breakfast = 0 WHERE push_missing_breakfast IS NULL")
|
||||||
database.execute("UPDATE user_settings SET push_missing_lunch = 0 WHERE push_missing_lunch IS NULL")
|
database.execute("UPDATE user_settings SET push_missing_lunch = 0 WHERE push_missing_lunch IS NULL")
|
||||||
database.execute("UPDATE user_settings SET push_missing_dinner = 0 WHERE push_missing_dinner IS NULL")
|
database.execute("UPDATE user_settings SET push_missing_dinner = 0 WHERE push_missing_dinner IS NULL")
|
||||||
|
|||||||
+408
-66
@@ -26,17 +26,30 @@ from .backup import RESTORE_CONFIRMATION_TEXT, export_backup_archive, restore_ba
|
|||||||
from .constants import (
|
from .constants import (
|
||||||
AVAILABILITY_LABELS,
|
AVAILABILITY_LABELS,
|
||||||
BUILDER_LABELS,
|
BUILDER_LABELS,
|
||||||
|
BUILDER_OPTIONS,
|
||||||
|
DAYPART_SLUG_TO_MEAL_TYPE,
|
||||||
DEFAULT_CATEGORY_BUILDERS,
|
DEFAULT_CATEGORY_BUILDERS,
|
||||||
DAY_TEMPLATE_NAME_SUGGESTIONS,
|
DAY_TEMPLATE_NAME_SUGGESTIONS,
|
||||||
DEFAULT_CATEGORIES,
|
DEFAULT_CATEGORIES,
|
||||||
ENERGY_DENSITY_LABELS,
|
ENERGY_DENSITY_LABELS,
|
||||||
ENERGY_DENSITY_OPTIONS,
|
ENERGY_DENSITY_OPTIONS,
|
||||||
|
FOOD_ROLE_DESCRIPTIONS,
|
||||||
|
FOOD_ROLE_LABELS,
|
||||||
|
FOOD_ROLE_OPTIONS,
|
||||||
ITEM_KIND_LABELS,
|
ITEM_KIND_LABELS,
|
||||||
ITEM_KIND_SINGULAR_LABELS,
|
ITEM_KIND_SINGULAR_LABELS,
|
||||||
ITEM_SET_NAME_SUGGESTIONS,
|
ITEM_SET_NAME_SUGGESTIONS,
|
||||||
|
MEAL_STYLE_LABELS,
|
||||||
|
MEAL_STYLE_OPTIONS,
|
||||||
|
MEAL_TYPE_LABELS,
|
||||||
|
MEAL_TYPE_OPTIONS,
|
||||||
NOTIFICATION_CHANNEL_OPTIONS,
|
NOTIFICATION_CHANNEL_OPTIONS,
|
||||||
|
PROTEIN_PREFERENCE_LABELS,
|
||||||
|
PROTEIN_PREFERENCE_OPTIONS,
|
||||||
SUGGESTION_STYLE_LABELS,
|
SUGGESTION_STYLE_LABELS,
|
||||||
SUGGESTION_STYLE_OPTIONS,
|
SUGGESTION_STYLE_OPTIONS,
|
||||||
|
SUGGESTION_PRIORITY_LABELS,
|
||||||
|
SUGGESTION_PRIORITY_OPTIONS,
|
||||||
VISIBILITY_DESCRIPTIONS,
|
VISIBILITY_DESCRIPTIONS,
|
||||||
VISIBILITY_LABELS,
|
VISIBILITY_LABELS,
|
||||||
WEEKDAY_OPTIONS,
|
WEEKDAY_OPTIONS,
|
||||||
@@ -196,6 +209,7 @@ def default_user_settings() -> dict:
|
|||||||
"notification_channel": "in_app",
|
"notification_channel": "in_app",
|
||||||
"suggestion_style": suggestion_style,
|
"suggestion_style": suggestion_style,
|
||||||
"energy_preference": suggestion_style_energy_preference(suggestion_style),
|
"energy_preference": suggestion_style_energy_preference(suggestion_style),
|
||||||
|
"protein_preference": "mixed",
|
||||||
"remind_before_shopping": True,
|
"remind_before_shopping": True,
|
||||||
"remind_on_shopping_day": True,
|
"remind_on_shopping_day": True,
|
||||||
"show_missing_for_upcoming_week": True,
|
"show_missing_for_upcoming_week": True,
|
||||||
@@ -261,6 +275,7 @@ def get_user_settings() -> dict:
|
|||||||
settings["notification_channel"] = settings.get("notification_channel") or "in_app"
|
settings["notification_channel"] = settings.get("notification_channel") or "in_app"
|
||||||
settings["suggestion_style"] = normalize_suggestion_style(settings.get("suggestion_style"), "balanced")
|
settings["suggestion_style"] = normalize_suggestion_style(settings.get("suggestion_style"), "balanced")
|
||||||
settings["energy_preference"] = suggestion_style_energy_preference(settings["suggestion_style"])
|
settings["energy_preference"] = suggestion_style_energy_preference(settings["suggestion_style"])
|
||||||
|
settings["protein_preference"] = normalize_protein_preference(settings.get("protein_preference"), "mixed")
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
||||||
@@ -283,8 +298,6 @@ def normalize_notification_channel(raw: str | None, default: str = "in_app") ->
|
|||||||
|
|
||||||
def normalize_suggestion_style(raw: str | None, default: str = "balanced") -> str:
|
def normalize_suggestion_style(raw: str | None, default: str = "balanced") -> str:
|
||||||
allowed = {value for value, _label in SUGGESTION_STYLE_OPTIONS}
|
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
|
return raw if raw in allowed else default
|
||||||
|
|
||||||
|
|
||||||
@@ -293,9 +306,57 @@ def normalize_energy_density(raw: str | None, default: str = "neutral") -> str:
|
|||||||
return raw if raw in allowed else default
|
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_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:
|
def suggestion_style_energy_preference(style: str) -> str:
|
||||||
if style == "fitness":
|
if style == "fitness":
|
||||||
return "low"
|
return "low"
|
||||||
|
if style == "easy":
|
||||||
|
return "low"
|
||||||
|
if style == "snack":
|
||||||
|
return "neutral"
|
||||||
return "neutral"
|
return "neutral"
|
||||||
|
|
||||||
|
|
||||||
@@ -365,6 +426,23 @@ def describe_record(entry: dict) -> dict:
|
|||||||
entry["target_name"] = target_name
|
entry["target_name"] = target_name
|
||||||
entry["energy_density"] = normalize_energy_density(entry.get("energy_density"), "neutral")
|
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["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["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_personal"] = entry.get("visibility") == "personal"
|
||||||
entry["is_shared"] = entry.get("visibility") == "shared"
|
entry["is_shared"] = entry.get("visibility") == "shared"
|
||||||
entry["is_mine"] = entry.get("owner_user_id") == g.user["id"]
|
entry["is_mine"] = entry.get("owner_user_id") == g.user["id"]
|
||||||
@@ -516,7 +594,6 @@ def attach_builder_keys(items: list[dict]) -> list[dict]:
|
|||||||
if not items:
|
if not items:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
category_builder_map = get_category_builder_map()
|
|
||||||
meal_ids = [item["id"] for item in items if item["kind"] == "meal"]
|
meal_ids = [item["id"] for item in items if item["kind"] == "meal"]
|
||||||
meal_builder_map: dict[int, set[str]] = defaultdict(set)
|
meal_builder_map: dict[int, set[str]] = defaultdict(set)
|
||||||
|
|
||||||
@@ -525,7 +602,7 @@ def attach_builder_keys(items: list[dict]) -> list[dict]:
|
|||||||
rows = get_db().execute(
|
rows = get_db().execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT meal_components.meal_item_id,
|
SELECT meal_components.meal_item_id,
|
||||||
component.category
|
component.base_type
|
||||||
FROM meal_components
|
FROM meal_components
|
||||||
JOIN items AS component ON component.id = meal_components.food_item_id
|
JOIN items AS component ON component.id = meal_components.food_item_id
|
||||||
WHERE meal_components.meal_item_id IN ({placeholders})
|
WHERE meal_components.meal_item_id IN ({placeholders})
|
||||||
@@ -533,7 +610,7 @@ def attach_builder_keys(items: list[dict]) -> list[dict]:
|
|||||||
meal_ids,
|
meal_ids,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
for row in rows:
|
for row in rows:
|
||||||
builder_key = category_builder_map.get(row["category"] or "", "neutral")
|
builder_key = normalize_base_type(row["base_type"], "neutral")
|
||||||
meal_builder_map[int(row["meal_item_id"])].add(builder_key)
|
meal_builder_map[int(row["meal_item_id"])].add(builder_key)
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -541,9 +618,9 @@ def attach_builder_keys(items: list[dict]) -> list[dict]:
|
|||||||
if item["kind"] == "meal":
|
if item["kind"] == "meal":
|
||||||
builder_keys = sorted(meal_builder_map.get(item["id"], set()))
|
builder_keys = sorted(meal_builder_map.get(item["id"], set()))
|
||||||
if not builder_keys:
|
if not builder_keys:
|
||||||
builder_keys = [category_builder_map.get(item.get("category") or "", "neutral")]
|
builder_keys = [normalize_base_type(item.get("base_type"), "neutral")]
|
||||||
else:
|
else:
|
||||||
builder_keys = [category_builder_map.get(item.get("category") or "", "neutral")]
|
builder_keys = [normalize_base_type(item.get("base_type"), "neutral")]
|
||||||
item["builder_keys"] = builder_keys
|
item["builder_keys"] = builder_keys
|
||||||
item["builder_labels"] = [BUILDER_LABELS.get(key, BUILDER_LABELS["neutral"]) for key in 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"
|
item["primary_builder_key"] = builder_keys[0] if builder_keys else "neutral"
|
||||||
@@ -557,23 +634,22 @@ def decorate_items(rows) -> list[dict]:
|
|||||||
def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]:
|
def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]:
|
||||||
if not item_ids:
|
if not item_ids:
|
||||||
return {}
|
return {}
|
||||||
category_builder_map = get_category_builder_map()
|
|
||||||
placeholders = ",".join("?" for _ in item_ids)
|
placeholders = ",".join("?" for _ in item_ids)
|
||||||
rows = get_db().execute(
|
rows = get_db().execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT id, kind, category
|
SELECT id, kind, base_type
|
||||||
FROM items
|
FROM items
|
||||||
WHERE id IN ({placeholders})
|
WHERE id IN ({placeholders})
|
||||||
""",
|
""",
|
||||||
item_ids,
|
item_ids,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
builder_map: dict[int, set[str]] = {int(row["id"]): {category_builder_map.get(row["category"] or "", "neutral")} for row in rows}
|
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"]
|
meal_ids = [int(row["id"]) for row in rows if row["kind"] == "meal"]
|
||||||
if meal_ids:
|
if meal_ids:
|
||||||
meal_placeholders = ",".join("?" for _ in meal_ids)
|
meal_placeholders = ",".join("?" for _ in meal_ids)
|
||||||
component_rows = get_db().execute(
|
component_rows = get_db().execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT meal_components.meal_item_id, component.category
|
SELECT meal_components.meal_item_id, component.base_type
|
||||||
FROM meal_components
|
FROM meal_components
|
||||||
JOIN items AS component ON component.id = meal_components.food_item_id
|
JOIN items AS component ON component.id = meal_components.food_item_id
|
||||||
WHERE meal_components.meal_item_id IN ({meal_placeholders})
|
WHERE meal_components.meal_item_id IN ({meal_placeholders})
|
||||||
@@ -582,15 +658,74 @@ def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
for row in component_rows:
|
for row in component_rows:
|
||||||
builder_map.setdefault(int(row["meal_item_id"]), set()).add(
|
builder_map.setdefault(int(row["meal_item_id"]), set()).add(
|
||||||
category_builder_map.get(row["category"] or "", "neutral")
|
normalize_base_type(row["base_type"], "neutral")
|
||||||
)
|
)
|
||||||
return builder_map
|
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()))
|
||||||
|
if "fiber" in accepted and base_type in {"veg", "fruit"}:
|
||||||
|
return True
|
||||||
|
return base_type in accepted
|
||||||
|
|
||||||
|
|
||||||
def score_suggestion_components(component_items: list[dict], daypart_slug: str, settings: dict) -> int:
|
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"])}
|
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]
|
energy_values = [normalize_energy_density(item.get("energy_density"), "neutral") for item in component_items]
|
||||||
score = 0
|
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")
|
style = settings.get("suggestion_style", "balanced")
|
||||||
if style == "fitness":
|
if style == "fitness":
|
||||||
@@ -600,6 +735,12 @@ def score_suggestion_components(component_items: list[dict], daypart_slug: str,
|
|||||||
elif style == "protein":
|
elif style == "protein":
|
||||||
score += 8 if "protein" in builder_keys else 0
|
score += 8 if "protein" in builder_keys else 0
|
||||||
score += 3 if daypart_slug in {"lunch", "dinner"} and "veg" 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:
|
else:
|
||||||
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
|
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
|
||||||
score += 5 if "carb" in builder_keys else 0
|
score += 5 if "carb" in builder_keys else 0
|
||||||
@@ -607,7 +748,7 @@ def score_suggestion_components(component_items: list[dict], daypart_slug: str,
|
|||||||
else:
|
else:
|
||||||
score += 5 if "protein" in builder_keys else 0
|
score += 5 if "protein" in builder_keys else 0
|
||||||
score += 4 if "carb" in builder_keys else 0
|
score += 4 if "carb" in builder_keys else 0
|
||||||
score += 4 if "veg" in builder_keys else 0
|
score += 4 if builder_keys & {"veg", "fruit"} else 0
|
||||||
|
|
||||||
energy_preference = settings.get("energy_preference", "neutral")
|
energy_preference = settings.get("energy_preference", "neutral")
|
||||||
if style == "fitness":
|
if style == "fitness":
|
||||||
@@ -709,20 +850,44 @@ def group_items_by_availability(items: list[dict]) -> list[dict]:
|
|||||||
|
|
||||||
def extract_item_form_data(existing: dict | None = None) -> dict:
|
def extract_item_form_data(existing: dict | None = None) -> dict:
|
||||||
form_data = existing or {}
|
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)
|
||||||
form_data.update(
|
form_data.update(
|
||||||
{
|
{
|
||||||
"name": request.form.get("name", "").strip(),
|
"name": request.form.get("name", "").strip(),
|
||||||
"category": request.form.get("category", "").strip(),
|
"category": request.form.get("category", "").strip(),
|
||||||
|
"base_type": normalize_base_type(request.form.get("base_type"), form_data.get("base_type", "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": normalize_meal_type(request.form.get("meal_type"), meal_type_default),
|
||||||
|
"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")),
|
"energy_density": normalize_energy_density(request.form.get("energy_density"), form_data.get("energy_density", "neutral")),
|
||||||
"note": request.form.get("note", "").strip(),
|
"note": request.form.get("note", "").strip(),
|
||||||
"visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")),
|
"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_id": normalize_target_user_id(request.form.get("target_user_id")),
|
||||||
"target_user_raw": request.form.get("target_user_id", TARGET_USER_OPTIONS_DEFAULT),
|
"target_user_raw": request.form.get("target_user_id", TARGET_USER_OPTIONS_DEFAULT),
|
||||||
"food_search": request.form.get("food_search", "").strip(),
|
"food_search": request.form.get("food_search", "").strip(),
|
||||||
"daypart_ids": [int(value) for value in request.form.getlist("daypart_ids") if value.isdigit()],
|
"daypart_ids": daypart_ids,
|
||||||
"component_ids": [int(value) for value in request.form.getlist("component_ids") if value.isdigit()],
|
"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_name": request.form.get("quick_food_name", "").strip(),
|
||||||
"quick_food_category": request.form.get("quick_food_category", "").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_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_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(),
|
"quick_food_note": request.form.get("quick_food_note", "").strip(),
|
||||||
}
|
}
|
||||||
@@ -734,9 +899,9 @@ def create_quick_food_from_form(form_data: dict) -> int:
|
|||||||
cursor = get_db().execute(
|
cursor = get_db().execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO items (
|
INSERT INTO items (
|
||||||
household_id, owner_user_id, target_user_id, visibility, kind, name, category, energy_density, note, created_by, updated_by
|
household_id, owner_user_id, target_user_id, visibility, kind, name, category, base_type, suggestion_role, suggestion_priority, can_be_meal_core, energy_density, note, created_by, updated_by
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
current_household_id(),
|
current_household_id(),
|
||||||
@@ -745,6 +910,10 @@ def create_quick_food_from_form(form_data: dict) -> int:
|
|||||||
form_data["visibility"],
|
form_data["visibility"],
|
||||||
form_data["quick_food_name"],
|
form_data["quick_food_name"],
|
||||||
form_data["quick_food_category"],
|
form_data["quick_food_category"],
|
||||||
|
form_data["quick_food_base_type"],
|
||||||
|
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_energy_density"],
|
||||||
form_data["quick_food_note"],
|
form_data["quick_food_note"],
|
||||||
g.user["id"],
|
g.user["id"],
|
||||||
@@ -1269,6 +1438,41 @@ def get_daypart_by_id(daypart_id: int):
|
|||||||
return None
|
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 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:
|
def format_item_names(items: list[dict], limit: int = 3) -> str:
|
||||||
return ", ".join(item["name"] for item in items[:limit])
|
return ", ".join(item["name"] for item in items[:limit])
|
||||||
|
|
||||||
@@ -1312,56 +1516,110 @@ def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str:
|
|||||||
return names[0]
|
return names[0]
|
||||||
|
|
||||||
|
|
||||||
def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]:
|
def meal_pattern_definitions(daypart_slug: str) -> list[dict]:
|
||||||
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
|
if daypart_slug == "breakfast":
|
||||||
target_patterns = [
|
return [
|
||||||
{
|
{
|
||||||
"slots": ({"carb"}, {"dairy", "protein"}, {"fruit", "nuts", "seeds"}),
|
"reason": "Passt gut zu Frühstück",
|
||||||
"reason": "Passt gut zu Frühstück oder Snack",
|
"slots": [
|
||||||
|
{"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True},
|
||||||
|
{"base_types": {"dairy", "protein"}, "roles": {"base", "main", "complement", "solo", "snack"}, "core_only": False},
|
||||||
|
{"base_types": {"fruit"}, "roles": {"complement", "topping", "snack", "base"}, "core_only": False},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reason": "Passt gut für Frühstück",
|
||||||
|
"slots": [
|
||||||
|
{"base_types": {"dairy"}, "roles": {"base", "main", "solo", "snack"}, "core_only": True},
|
||||||
|
{"base_types": {"carb"}, "roles": {"base", "main", "complement", "solo"}, "core_only": False},
|
||||||
|
{"base_types": {"nuts", "seeds", "fruit"}, "roles": {"topping", "complement", "snack"}, "core_only": False},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
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},
|
||||||
|
{"base_types": {"fruit"}, "roles": {"complement", "snack", "topping"}, "core_only": False},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slots": ({"carb"}, {"dairy", "protein"}),
|
|
||||||
"reason": "Zuhause schnell kombinierbar",
|
"reason": "Zuhause schnell kombinierbar",
|
||||||
|
"slots": [
|
||||||
|
{"base_types": {"fruit"}, "roles": {"base", "snack", "complement"}, "core_only": True},
|
||||||
|
{"base_types": {"nuts", "seeds"}, "roles": {"topping", "snack", "complement"}, "core_only": False},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slots": ({"dairy", "protein"}, {"fruit", "nuts", "seeds"}),
|
"reason": "Passt gut zu einem kleinen Snack",
|
||||||
"reason": "Lässt sich gut als kleiner Snack vormerken",
|
"slots": [
|
||||||
},
|
{"base_types": {"carb"}, "roles": {"solo", "base", "snack"}, "core_only": True},
|
||||||
]
|
{"base_types": {"protein", "dairy"}, "roles": {"complement", "snack", "base"}, "core_only": False},
|
||||||
else:
|
],
|
||||||
target_patterns = [
|
|
||||||
{
|
|
||||||
"slots": ({"protein"}, {"carb"}, {"veg"}),
|
|
||||||
"reason": "Zuhause als vollständige Mahlzeit möglich",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"slots": ({"protein"}, {"carb"}),
|
|
||||||
"reason": "Lässt sich leicht ergänzen",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"slots": ({"protein"}, {"veg"}),
|
|
||||||
"reason": "Zuhause schon gut kombinierbar",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"slots": ({"carb"}, {"veg"}),
|
|
||||||
"reason": "Daraus kann schnell etwas Einfaches werden",
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"reason": "Zuhause als vollständige Mahlzeit möglich",
|
||||||
|
"slots": [
|
||||||
|
{"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True},
|
||||||
|
{"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True},
|
||||||
|
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reason": "Lässt sich gut ergänzen",
|
||||||
|
"slots": [
|
||||||
|
{"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True},
|
||||||
|
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False},
|
||||||
|
{"base_types": {"carb"}, "roles": {"base", "complement", "solo"}, "core_only": False},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reason": "Schnell und alltagstauglich",
|
||||||
|
"slots": [
|
||||||
|
{"base_types": {"carb", "protein"}, "roles": {"solo"}, "core_only": True},
|
||||||
|
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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] = []
|
suggestions: list[dict] = []
|
||||||
seen_signatures: set[tuple[int, ...]] = set()
|
seen_signatures: set[tuple[int, ...]] = set()
|
||||||
|
|
||||||
def slot_matches(food: dict, slot_keys: set[str]) -> bool:
|
|
||||||
return bool(slot_keys & set(food.get("builder_keys", ["neutral"])))
|
|
||||||
|
|
||||||
for pattern in target_patterns:
|
for pattern in target_patterns:
|
||||||
slot_candidates = []
|
slot_candidates = []
|
||||||
for slot_keys in pattern["slots"]:
|
for slot in pattern["slots"]:
|
||||||
matches = [food for food in home_foods if slot_matches(food, slot_keys)]
|
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:
|
if not matches:
|
||||||
slot_candidates = []
|
slot_candidates = []
|
||||||
break
|
break
|
||||||
slot_candidates.append(matches)
|
slot_candidates.append(matches[:6])
|
||||||
if not slot_candidates:
|
if not slot_candidates:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1384,6 +1642,7 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li
|
|||||||
"needs_shopping": False,
|
"needs_shopping": False,
|
||||||
"is_generated": True,
|
"is_generated": True,
|
||||||
"suggestion_key": generated_suggestion_key([item["id"] for item in combo_items]),
|
"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:
|
if len(suggestions) >= limit * 3:
|
||||||
@@ -1511,14 +1770,25 @@ def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | Non
|
|||||||
present = set()
|
present = set()
|
||||||
for keys in builder_map.values():
|
for keys in builder_map.values():
|
||||||
present.update(keys)
|
present.update(keys)
|
||||||
target_order = ["protein", "carb", "veg"]
|
has_fiber = bool(present & {"veg", "fruit"})
|
||||||
missing = [key for key in target_order if key not in present]
|
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:
|
if not missing:
|
||||||
return None
|
return None
|
||||||
first_missing = missing[0]
|
first_missing = missing[0]
|
||||||
home_matches = [
|
home_matches = [
|
||||||
item for item in fetch_items(kind="food", availability="home", daypart_id=daypart_id)
|
item for item in fetch_items(kind="food", availability="home", daypart_id=daypart_id)
|
||||||
if first_missing in item.get("builder_keys", [])
|
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 = sorted(
|
||||||
home_matches,
|
home_matches,
|
||||||
@@ -1527,7 +1797,7 @@ def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | Non
|
|||||||
text_map = {
|
text_map = {
|
||||||
"protein": "Dazu könnte noch eine Proteinquelle gut passen.",
|
"protein": "Dazu könnte noch eine Proteinquelle gut passen.",
|
||||||
"carb": "Das lässt sich gut mit einer Kohlenhydratquelle ergänzen.",
|
"carb": "Das lässt sich gut mit einer Kohlenhydratquelle ergänzen.",
|
||||||
"veg": "Dazu könnte noch etwas Gemüse gut passen.",
|
"fiber": "Dazu könnte noch etwas Gemüse oder Obst gut passen.",
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"text": text_map.get(first_missing, "Dazu könnte noch etwas Kleines gut passen."),
|
"text": text_map.get(first_missing, "Dazu könnte noch etwas Kleines gut passen."),
|
||||||
@@ -1598,7 +1868,7 @@ def build_dashboard_hints(today: date) -> list[str]:
|
|||||||
visible_params(),
|
visible_params(),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if int(dinner_home["count"]) > 0:
|
if int(dinner_home["count"]) > 0:
|
||||||
hints.append("Zuhause ist bereits etwas da, das gut zu Abendessen passt.")
|
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"]:
|
if settings.get("remind_before_shopping") and (today + timedelta(days=1)).weekday() == household_settings["shopping_weekday"]:
|
||||||
upcoming = fetch_upcoming_shopping_needs(limit=3)
|
upcoming = fetch_upcoming_shopping_needs(limit=3)
|
||||||
@@ -1608,7 +1878,7 @@ def build_dashboard_hints(today: date) -> list[str]:
|
|||||||
if settings.get("remind_nuts"):
|
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", []))]
|
nut_items = [item for item in fetch_items(kind="food", availability="home") if {"nuts", "seeds"} & set(item.get("builder_keys", []))]
|
||||||
if nut_items:
|
if nut_items:
|
||||||
hints.append("Heute schon an Nüsse gedacht?")
|
hints.append("Vielleicht passt heute noch etwas mit Nüssen oder Saaten dazu.")
|
||||||
|
|
||||||
if settings.get("suggest_templates"):
|
if settings.get("suggest_templates"):
|
||||||
old_template = get_db().execute(
|
old_template = get_db().execute(
|
||||||
@@ -2580,6 +2850,12 @@ 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.get("category") or form_data.get("quick_food_category")
|
||||||
),
|
),
|
||||||
form_data=form_data,
|
form_data=form_data,
|
||||||
|
builder_options=[(key, label) for key, label in BUILDER_LABELS.items()],
|
||||||
|
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,
|
energy_density_options=ENERGY_DENSITY_OPTIONS,
|
||||||
visibility_options=VISIBILITY_FORM_OPTIONS,
|
visibility_options=VISIBILITY_FORM_OPTIONS,
|
||||||
target_user_options=get_target_user_options(),
|
target_user_options=get_target_user_options(),
|
||||||
@@ -2634,27 +2910,52 @@ def create_or_get_generated_meal(
|
|||||||
get_db().execute(
|
get_db().execute(
|
||||||
"""
|
"""
|
||||||
UPDATE items
|
UPDATE items
|
||||||
SET updated_by = ?, updated_at = CURRENT_TIMESTAMP
|
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 = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
(g.user["id"], meal_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()
|
get_db().commit()
|
||||||
return meal_id
|
return meal_id
|
||||||
|
|
||||||
|
component_foods = fetch_items_by_ids(list(normalized_ids))
|
||||||
cursor = get_db().execute(
|
cursor = get_db().execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO items (
|
INSERT INTO items (
|
||||||
household_id, owner_user_id, visibility, kind, name, category, created_by, updated_by
|
household_id,
|
||||||
|
owner_user_id,
|
||||||
|
visibility,
|
||||||
|
kind,
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
meal_type,
|
||||||
|
meal_tags,
|
||||||
|
energy_density,
|
||||||
|
created_by,
|
||||||
|
updated_by
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, 'meal', ?, ?, ?, ?)
|
VALUES (?, ?, ?, 'meal', ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
current_household_id(),
|
current_household_id(),
|
||||||
g.user["id"],
|
g.user["id"],
|
||||||
visibility,
|
visibility,
|
||||||
name,
|
name,
|
||||||
"Kleines Essen" if get_daypart_by_id(daypart_id)["slug"] in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} else "Warmes",
|
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"],
|
||||||
g.user["id"],
|
g.user["id"],
|
||||||
),
|
),
|
||||||
@@ -3124,6 +3425,7 @@ def settings_view():
|
|||||||
elif form_name == "reminders":
|
elif form_name == "reminders":
|
||||||
ensure_user_settings_row()
|
ensure_user_settings_row()
|
||||||
suggestion_style = normalize_suggestion_style(request.form.get("suggestion_style"), "balanced")
|
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(
|
get_db().execute(
|
||||||
"""
|
"""
|
||||||
UPDATE user_settings
|
UPDATE user_settings
|
||||||
@@ -3132,6 +3434,7 @@ def settings_view():
|
|||||||
notification_channel = ?,
|
notification_channel = ?,
|
||||||
suggestion_style = ?,
|
suggestion_style = ?,
|
||||||
energy_preference = ?,
|
energy_preference = ?,
|
||||||
|
protein_preference = ?,
|
||||||
remind_before_shopping = ?,
|
remind_before_shopping = ?,
|
||||||
remind_on_shopping_day = ?,
|
remind_on_shopping_day = ?,
|
||||||
show_missing_for_upcoming_week = ?,
|
show_missing_for_upcoming_week = ?,
|
||||||
@@ -3157,6 +3460,7 @@ def settings_view():
|
|||||||
normalize_notification_channel(request.form.get("notification_channel"), "in_app"),
|
normalize_notification_channel(request.form.get("notification_channel"), "in_app"),
|
||||||
suggestion_style,
|
suggestion_style,
|
||||||
suggestion_style_energy_preference(suggestion_style),
|
suggestion_style_energy_preference(suggestion_style),
|
||||||
|
protein_preference,
|
||||||
parse_checkbox("remind_before_shopping", True),
|
parse_checkbox("remind_before_shopping", True),
|
||||||
parse_checkbox("remind_on_shopping_day", True),
|
parse_checkbox("remind_on_shopping_day", True),
|
||||||
parse_checkbox("show_missing_for_upcoming_week", True),
|
parse_checkbox("show_missing_for_upcoming_week", True),
|
||||||
@@ -3379,6 +3683,12 @@ def item_create(kind: str):
|
|||||||
form_data = {
|
form_data = {
|
||||||
"name": request.args.get("name", "").strip(),
|
"name": request.args.get("name", "").strip(),
|
||||||
"category": "",
|
"category": "",
|
||||||
|
"base_type": "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",
|
"energy_density": "neutral",
|
||||||
"note": "",
|
"note": "",
|
||||||
"visibility": "shared",
|
"visibility": "shared",
|
||||||
@@ -3389,6 +3699,10 @@ def item_create(kind: str):
|
|||||||
"component_ids": [int(value) for value in request.args.getlist("component_ids") if value.isdigit()],
|
"component_ids": [int(value) for value in request.args.getlist("component_ids") if value.isdigit()],
|
||||||
"quick_food_name": "",
|
"quick_food_name": "",
|
||||||
"quick_food_category": "",
|
"quick_food_category": "",
|
||||||
|
"quick_food_base_type": "neutral",
|
||||||
|
"quick_food_role": "base",
|
||||||
|
"quick_food_priority": "normal",
|
||||||
|
"quick_food_can_be_meal_core": False,
|
||||||
"quick_food_energy_density": "neutral",
|
"quick_food_energy_density": "neutral",
|
||||||
"quick_food_note": "",
|
"quick_food_note": "",
|
||||||
}
|
}
|
||||||
@@ -3428,9 +3742,9 @@ def item_create(kind: str):
|
|||||||
cursor = get_db().execute(
|
cursor = get_db().execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO items (
|
INSERT INTO items (
|
||||||
household_id, owner_user_id, target_user_id, visibility, kind, name, category, energy_density, note, photo_filename, created_by, updated_by
|
household_id, owner_user_id, target_user_id, visibility, kind, name, category, base_type, suggestion_role, suggestion_priority, can_be_meal_core, meal_type, meal_tags, energy_density, note, photo_filename, created_by, updated_by
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
current_household_id(),
|
current_household_id(),
|
||||||
@@ -3439,7 +3753,13 @@ def item_create(kind: str):
|
|||||||
form_data["visibility"],
|
form_data["visibility"],
|
||||||
kind,
|
kind,
|
||||||
form_data["name"],
|
form_data["name"],
|
||||||
form_data["category"],
|
form_data["category"] if kind == "food" else None,
|
||||||
|
form_data["base_type"] 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["energy_density"],
|
||||||
form_data["note"],
|
form_data["note"],
|
||||||
photo_filename,
|
photo_filename,
|
||||||
@@ -3472,6 +3792,12 @@ def item_edit(item_id: int):
|
|||||||
form_data = {
|
form_data = {
|
||||||
"name": item["name"],
|
"name": item["name"],
|
||||||
"category": item["category"] or "",
|
"category": item["category"] or "",
|
||||||
|
"base_type": item.get("base_type") 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": decode_tag_list(item.get("meal_tags")),
|
||||||
"energy_density": item.get("energy_density") or "neutral",
|
"energy_density": item.get("energy_density") or "neutral",
|
||||||
"note": item["note"] or "",
|
"note": item["note"] or "",
|
||||||
"visibility": item["visibility"],
|
"visibility": item["visibility"],
|
||||||
@@ -3482,6 +3808,10 @@ def item_edit(item_id: int):
|
|||||||
"component_ids": get_meal_component_ids(item_id) if item["kind"] == "meal" else [],
|
"component_ids": get_meal_component_ids(item_id) if item["kind"] == "meal" else [],
|
||||||
"quick_food_name": "",
|
"quick_food_name": "",
|
||||||
"quick_food_category": "",
|
"quick_food_category": "",
|
||||||
|
"quick_food_base_type": "neutral",
|
||||||
|
"quick_food_role": "base",
|
||||||
|
"quick_food_priority": "normal",
|
||||||
|
"quick_food_can_be_meal_core": False,
|
||||||
"quick_food_energy_density": "neutral",
|
"quick_food_energy_density": "neutral",
|
||||||
"quick_food_note": "",
|
"quick_food_note": "",
|
||||||
}
|
}
|
||||||
@@ -3523,6 +3853,12 @@ def item_edit(item_id: int):
|
|||||||
UPDATE items
|
UPDATE items
|
||||||
SET name = ?,
|
SET name = ?,
|
||||||
category = ?,
|
category = ?,
|
||||||
|
base_type = ?,
|
||||||
|
suggestion_role = ?,
|
||||||
|
suggestion_priority = ?,
|
||||||
|
can_be_meal_core = ?,
|
||||||
|
meal_type = ?,
|
||||||
|
meal_tags = ?,
|
||||||
energy_density = ?,
|
energy_density = ?,
|
||||||
note = ?,
|
note = ?,
|
||||||
visibility = ?,
|
visibility = ?,
|
||||||
@@ -3534,7 +3870,13 @@ def item_edit(item_id: int):
|
|||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
form_data["name"],
|
form_data["name"],
|
||||||
form_data["category"],
|
form_data["category"] if item["kind"] == "food" else None,
|
||||||
|
form_data["base_type"] 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["energy_density"],
|
||||||
form_data["note"],
|
form_data["note"],
|
||||||
form_data["visibility"],
|
form_data["visibility"],
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ CREATE TABLE IF NOT EXISTS user_settings (
|
|||||||
notification_channel TEXT NOT NULL DEFAULT 'in_app',
|
notification_channel TEXT NOT NULL DEFAULT 'in_app',
|
||||||
suggestion_style TEXT NOT NULL DEFAULT 'balanced',
|
suggestion_style TEXT NOT NULL DEFAULT 'balanced',
|
||||||
energy_preference TEXT NOT NULL DEFAULT 'neutral',
|
energy_preference TEXT NOT NULL DEFAULT 'neutral',
|
||||||
|
protein_preference TEXT NOT NULL DEFAULT 'mixed',
|
||||||
remind_before_shopping INTEGER NOT NULL DEFAULT 1,
|
remind_before_shopping INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_on_shopping_day INTEGER NOT NULL DEFAULT 1,
|
remind_on_shopping_day INTEGER NOT NULL DEFAULT 1,
|
||||||
show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
|
show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
|
||||||
@@ -120,6 +121,12 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
|
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
category TEXT,
|
category TEXT,
|
||||||
|
base_type TEXT NOT NULL DEFAULT 'neutral',
|
||||||
|
suggestion_role TEXT NOT NULL DEFAULT 'base',
|
||||||
|
suggestion_priority TEXT NOT NULL DEFAULT 'normal',
|
||||||
|
can_be_meal_core INTEGER NOT NULL DEFAULT 0,
|
||||||
|
meal_type TEXT,
|
||||||
|
meal_tags TEXT NOT NULL DEFAULT '',
|
||||||
energy_density TEXT NOT NULL DEFAULT 'neutral',
|
energy_density TEXT NOT NULL DEFAULT 'neutral',
|
||||||
note TEXT,
|
note TEXT,
|
||||||
photo_filename TEXT,
|
photo_filename TEXT,
|
||||||
|
|||||||
Reference in New Issue
Block a user