mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-10 18:03:11 -05:00
feat: Add recipe as ingredient (#4800)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
ff42964537
commit
60d9294861
@@ -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 ###
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user