feat: Add recipe as ingredient (#4800)

Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
gpotter@gmail.com
2025-11-03 21:57:57 -08:00
committed by GitHub
parent ff42964537
commit 60d9294861
27 changed files with 1037 additions and 80 deletions

View File

@@ -0,0 +1,40 @@
"""'Add referenced_recipe to ingredients'
Revision ID: 1d9a002d7234
Revises: e6bb583aac2d
Create Date: 2025-09-10 19:21:48.479101
"""
import sqlalchemy as sa
from alembic import op
import mealie.db.migration_types
# revision identifiers, used by Alembic.
revision = "1d9a002d7234"
down_revision: str | None = "e6bb583aac2d"
branch_labels: str | tuple[str, ...] | None = None
depends_on: str | tuple[str, ...] | None = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("recipes_ingredients", schema=None) as batch_op:
batch_op.add_column(sa.Column("referenced_recipe_id", mealie.db.migration_types.GUID(), nullable=True))
batch_op.create_index(
batch_op.f("ix_recipes_ingredients_referenced_recipe_id"), ["referenced_recipe_id"], unique=False
)
batch_op.create_foreign_key("fk_recipe_subrecipe", "recipes", ["referenced_recipe_id"], ["id"])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("recipes_ingredients", schema=None) as batch_op:
batch_op.drop_constraint("fk_recipe_subrecipe", type_="foreignkey")
batch_op.drop_index(batch_op.f("ix_recipes_ingredients_referenced_recipe_id"))
batch_op.drop_column("referenced_recipe_id")
# ### end Alembic commands ###

View File

@@ -22,6 +22,14 @@ class PermissionDenied(Exception):
pass
class RecursiveRecipe(Exception):
"""
This exception is raised when a recipe references itself, either directly or indirectly.
"""
pass
class SlugError(Exception):
"""
This exception is raised when the recipe name generates an invalid slug.
@@ -47,6 +55,7 @@ def mealie_registered_exceptions(t: Translator) -> dict:
PermissionDenied: t.t("exceptions.permission-denied"),
NoEntryFound: t.t("exceptions.no-entry-found"),
IntegrityError: t.t("exceptions.integrity-error"),
RecursiveRecipe: t.t("exceptions.recursive-recipe-link"),
}

View File

@@ -16,7 +16,7 @@ from .._model_utils.guid import GUID
if TYPE_CHECKING:
from ..group import Group
from ..household import Household
from .recipe import RecipeModel
households_to_ingredient_foods = sa.Table(
"households_to_ingredient_foods",
@@ -358,6 +358,12 @@ class RecipeIngredientModel(SqlAlchemyBase, BaseMixins):
reference_id: Mapped[GUID | None] = mapped_column(GUID) # Reference Links
# Recipe Reference
referenced_recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
referenced_recipe: Mapped["RecipeModel"] = orm.relationship(
"RecipeModel", back_populates="referenced_ingredients", foreign_keys=[referenced_recipe_id]
)
# Automatically updated by sqlalchemy event, do not write to this manually
note_normalized: Mapped[str | None] = mapped_column(String, index=True)
original_text_normalized: Mapped[str | None] = mapped_column(String, index=True)

View File

@@ -14,6 +14,7 @@ from sqlalchemy.orm.session import object_session
from mealie.db.models._model_utils.auto_init import auto_init
from mealie.db.models._model_utils.datetime import NaiveDateTime, get_utc_today
from mealie.db.models._model_utils.guid import GUID
from mealie.db.models.recipe.ingredient import RecipeIngredientModel
from .._model_base import BaseMixins, SqlAlchemyBase
from ..household.household_to_recipe import HouseholdToRecipe
@@ -22,7 +23,6 @@ from .api_extras import ApiExtras, api_extras
from .assets import RecipeAsset
from .category import recipes_to_categories
from .comment import RecipeComment
from .ingredient import RecipeIngredientModel
from .instruction import RecipeInstruction
from .note import Note
from .nutrition import Nutrition
@@ -100,11 +100,17 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
)
tools: Mapped[list["Tool"]] = orm.relationship("Tool", secondary=recipes_to_tools, back_populates="recipes")
recipe_ingredient: Mapped[list[RecipeIngredientModel]] = orm.relationship(
recipe_ingredient: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
"RecipeIngredientModel",
cascade="all, delete-orphan",
order_by="RecipeIngredientModel.position",
collection_class=ordering_list("position"),
foreign_keys="RecipeIngredientModel.recipe_id",
)
referenced_ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
"RecipeIngredientModel",
foreign_keys="RecipeIngredientModel.referenced_recipe_id",
back_populates="referenced_recipe",
)
recipe_instructions: Mapped[list[RecipeInstruction]] = orm.relationship(
"RecipeInstruction",

View File

@@ -33,6 +33,7 @@
},
"exceptions": {
"permission_denied": "You do not have permission to perform this action",
"recursive-recipe-link": "A recipe cannot reference itself, either directly or indirectly",
"no-entry-found": "The requested resource was not found",
"integrity-error": "Database integrity error",
"username-conflict-error": "This username is already taken",

View File

@@ -94,6 +94,12 @@ class RecipeController(BaseRecipeController):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ErrorResponse.respond(message="Recipe already exists")
)
elif thrownType == exceptions.RecursiveRecipe:
self.logger.error("Recursive Recipe Link Error on recipe controller action")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ErrorResponse.respond(message=self.t("exceptions.recursive-recipe-link")),
)
elif thrownType == exceptions.SlugError:
self.logger.error("Failed to generate a valid slug from recipe name")
raise HTTPException(

View File

@@ -14,6 +14,7 @@ from mealie.db.models.recipe import IngredientFoodModel
from mealie.schema._mealie import MealieModel
from mealie.schema._mealie.mealie_model import UpdatedAtField
from mealie.schema._mealie.types import NoneFloat
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.response.pagination import PaginationBase
INGREDIENT_QTY_PRECISION = 3
@@ -155,8 +156,9 @@ class RecipeIngredientBase(MealieModel):
quantity: NoneFloat = 0
unit: IngredientUnit | CreateIngredientUnit | None = None
food: IngredientFood | CreateIngredientFood | None = None
note: str | None = ""
referenced_recipe: Recipe | None = None
note: str | None = ""
display: str = ""
"""
How the ingredient should be displayed

View File

@@ -20,6 +20,7 @@ from mealie.schema.household.group_shopping_list import (
ShoppingListOut,
ShoppingListSave,
)
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_ingredient import (
IngredientFood,
IngredientUnit,
@@ -315,10 +316,22 @@ class ShoppingListService:
list_items: list[ShoppingListItemCreate] = []
for ingredient in recipe_ingredients:
if isinstance(ingredient.referenced_recipe, Recipe):
# Recursively process sub-recipe ingredients
sub_recipe = ingredient.referenced_recipe
sub_scale = (ingredient.quantity or 1) * scale
sub_items = self.get_shopping_list_items_from_recipe(
list_id,
sub_recipe.id,
sub_scale,
sub_recipe.recipe_ingredient,
)
list_items.extend(sub_items)
continue
if isinstance(ingredient.food, IngredientFood):
food_id = ingredient.food.id
label_id = ingredient.food.label_id
else:
food_id = None
label_id = None

View File

@@ -369,6 +369,27 @@ class RecipeService(RecipeServiceBase):
return new_recipe
def has_recursive_recipe_link(self, recipe: Recipe, visited: set[str] | None = None):
"""Recursively checks if a recipe links to itself through its ingredients."""
if visited is None:
visited = set()
recipe_id = str(getattr(recipe, "id", None))
if recipe_id in visited:
return True
visited.add(recipe_id)
ingredients = getattr(recipe, "recipe_ingredient", [])
for ing in ingredients:
try:
sub_recipe = self.get_one(ing.referenced_recipe.id)
except (AttributeError, exceptions.NoEntryFound):
continue
if self.has_recursive_recipe_link(sub_recipe, visited):
return True
return False
def _pre_update_check(self, slug_or_id: str | UUID, new_data: Recipe) -> Recipe:
"""
gets the recipe from the database and performs a check to see if the user can update the recipe.
@@ -399,6 +420,9 @@ class RecipeService(RecipeServiceBase):
if setting_lock and not self.can_lock_unlock(recipe):
raise exceptions.PermissionDenied("You do not have permission to lock/unlock this recipe.")
if self.has_recursive_recipe_link(new_data):
raise exceptions.RecursiveRecipe("Recursive recipe link detected. Update aborted.")
return recipe
def update_one(self, slug_or_id: str | UUID, update_data: Recipe) -> Recipe: