diff --git a/docs/docs/overrides/api.html b/docs/docs/overrides/api.html
index 80f88d9aa..7fab21795 100644
--- a/docs/docs/overrides/api.html
+++ b/docs/docs/overrides/api.html
@@ -14,7 +14,7 @@
diff --git a/frontend/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue b/frontend/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue
index a55fd14c1..f371a27a7 100644
--- a/frontend/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue
+++ b/frontend/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue
@@ -139,7 +139,7 @@
color="secondary"
density="compact"
/>
-
+
{
- const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
- return {
- checked: !householdsWithFood.includes(userHousehold.value),
- ingredient: ing,
- };
+ const shoppingListIngredients: ShoppingListIngredient[] = [];
+ function flattenRecipeIngredients(ing: RecipeIngredient, parentTitle = ""): ShoppingListIngredient[] {
+ const householdsWithFood = ing.food?.householdsWithIngredientFood || [];
+ if (ing.referencedRecipe) {
+ // Recursively flatten all ingredients in the referenced recipe
+ return (ing.referencedRecipe.recipeIngredient ?? []).flatMap((subIng) => {
+ const calculatedQty = (ing.quantity || 1) * (subIng.quantity || 1);
+ // Pass the referenced recipe name as the section title
+ return flattenRecipeIngredients(
+ { ...subIng, quantity: calculatedQty },
+ "",
+ );
+ });
+ }
+ else {
+ // Regular ingredient
+ return [{
+ checked: !householdsWithFood.includes(userHousehold.value),
+ ingredient: {
+ ...ing,
+ title: ing.title || parentTitle,
+ },
+ }];
+ }
+ }
+
+ recipe.recipeIngredient.forEach((ing) => {
+ const flattened = flattenRecipeIngredients(ing, "");
+ shoppingListIngredients.push(...flattened);
});
let currentTitle = "";
@@ -301,6 +324,9 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
if (ing.ingredient.title) {
currentTitle = ing.ingredient.title;
}
+ else if (ing.ingredient.referencedRecipe?.name) {
+ currentTitle = ing.ingredient.referencedRecipe.name;
+ }
// If this is the first item in the section, create a new section
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
@@ -316,7 +342,7 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
}
// Store the on-hand ingredients for later
- const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
+ const householdsWithFood = (ing.ingredient?.food?.householdsWithIngredientFood || []);
if (householdsWithFood.includes(userHousehold.value)) {
onHandIngs.push(ing);
return sections;
diff --git a/frontend/components/Domain/Recipe/RecipeDialogBulkAdd.vue b/frontend/components/Domain/Recipe/RecipeDialogBulkAdd.vue
index 23020e4e0..dc6bfae76 100644
--- a/frontend/components/Domain/Recipe/RecipeDialogBulkAdd.vue
+++ b/frontend/components/Domain/Recipe/RecipeDialogBulkAdd.vue
@@ -141,6 +141,13 @@ function save() {
dialog.value = false;
}
+function open() {
+ dialog.value = true;
+}
+function close() {
+ dialog.value = false;
+}
+
const i18n = useI18n();
const utilities = [
@@ -160,4 +167,10 @@ const utilities = [
action: splitByNumberedLine,
},
];
+
+// Expose functions to parent components
+defineExpose({
+ open,
+ close,
+});
diff --git a/frontend/components/Domain/Recipe/RecipeDialogPrintPreferences.vue b/frontend/components/Domain/Recipe/RecipeDialogPrintPreferences.vue
index 99f05ef9f..b20dd2bed 100644
--- a/frontend/components/Domain/Recipe/RecipeDialogPrintPreferences.vue
+++ b/frontend/components/Domain/Recipe/RecipeDialogPrintPreferences.vue
@@ -69,7 +69,14 @@
:label="$t('recipe.nutrition')"
/>
-
+
+
+
diff --git a/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue b/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue
index 4c779d81e..63175ec10 100644
--- a/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue
+++ b/frontend/components/Domain/Recipe/RecipeIngredientEditor.vue
@@ -41,6 +41,7 @@
+
+
+
+
+
+
({ required: true });
@@ -204,6 +236,10 @@ const props = defineProps({
type: String,
default: "body",
},
+ isRecipe: {
+ type: Boolean,
+ default: false,
+ },
unitError: {
type: Boolean,
default: false,
@@ -247,6 +283,7 @@ const { $globals } = useNuxtApp();
const state = reactive({
showTitle: false,
+ isRecipe: props.isRecipe,
});
const contextMenuOptions = computed(() => {
@@ -255,6 +292,10 @@ const contextMenuOptions = computed(() => {
text: i18n.t("recipe.toggle-section"),
event: "toggle-section",
},
+ {
+ text: i18n.t("recipe.toggle-recipe"),
+ event: "toggle-subrecipe",
+ },
{
text: i18n.t("recipe.insert-above"),
event: "insert-above",
@@ -303,6 +344,25 @@ async function createAssignFood() {
foodAutocomplete.value?.blur();
}
+// Recipes
+const route = useRoute();
+const $auth = useMealieAuth();
+const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
+
+const { isOwnGroup } = useLoggedInState();
+const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
+const search = useRecipeSearch(api);
+const loading = ref(false);
+const selectedIndex = ref(-1);
+// Reset or Grab Recipes on Change
+watch(loading, (val) => {
+ if (!val) {
+ search.query.value = "";
+ selectedIndex.value = -1;
+ search.data.value = [];
+ }
+});
+
// Units
const unitStore = useUnitStore();
const unitsData = useUnitData();
@@ -323,6 +383,17 @@ function toggleTitle() {
state.showTitle = !state.showTitle;
}
+function toggleIsRecipe() {
+ if (state.isRecipe) {
+ model.value.referencedRecipe = undefined;
+ }
+ else {
+ model.value.unit = undefined;
+ model.value.food = undefined;
+ }
+ state.isRecipe = !state.isRecipe;
+}
+
function handleUnitEnter() {
if (
model.value.unit === undefined
diff --git a/frontend/components/Domain/Recipe/RecipeIngredientListItem.vue b/frontend/components/Domain/Recipe/RecipeIngredientListItem.vue
index f4a50e6cb..3beaef88b 100644
--- a/frontend/components/Domain/Recipe/RecipeIngredientListItem.vue
+++ b/frontend/components/Domain/Recipe/RecipeIngredientListItem.vue
@@ -13,6 +13,10 @@
class="text-bold d-inline"
:source="parsedIng.note"
/>
+
+
+
+
(), {
scale: 1,
});
+const route = useRoute();
+const $auth = useMealieAuth();
+const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
const parsedIng = computed(() => {
- return useParsedIngredientText(props.ingredient, props.scale);
+ return useParsedIngredientText(props.ingredient, props.scale, true, groupSlug.value.toString());
});
diff --git a/frontend/components/Domain/Recipe/RecipeLastMade.vue b/frontend/components/Domain/Recipe/RecipeLastMade.vue
index 24e3bdeac..3e5c75efb 100644
--- a/frontend/components/Domain/Recipe/RecipeLastMade.vue
+++ b/frontend/components/Domain/Recipe/RecipeLastMade.vue
@@ -20,6 +20,29 @@
persistent-hint
rows="4"
/>
+
+
+ {{ $t('recipe.include-linked-recipes') }}
+
+
+
+
+
+
+
@@ -166,6 +189,21 @@ onMounted(async () => {
lastMadeReady.value = true;
});
+const childRecipes = computed(() => {
+ return props.recipe.recipeIngredient?.map((ingredient) => {
+ if (ingredient.referencedRecipe) {
+ return {
+ checked: false, // Default value for checked
+ recipeId: ingredient.referencedRecipe.id || "", // Non-nullable recipeId
+ ...ingredient.referencedRecipe, // Spread the rest of the referencedRecipe properties
+ };
+ }
+ else {
+ return undefined;
+ }
+ }).filter(recipe => recipe !== undefined); // Filter out undefined values
+});
+
whenever(
() => madeThisDialog.value,
() => {
@@ -250,6 +288,37 @@ async function createTimelineEvent() {
}
}
+ for (const childRecipe of childRecipes.value || []) {
+ if (!childRecipe.checked) {
+ continue;
+ }
+
+ const childTimelineEvent = {
+ ...newTimelineEvent.value,
+ recipeId: childRecipe.recipeId,
+ eventMessage: i18n.t("recipe.made-for-recipe", { recipe: childRecipe.name }),
+ image: undefined,
+ };
+ try {
+ await userApi.recipes.createTimelineEvent(childTimelineEvent);
+ }
+ catch (error) {
+ console.error(`Failed to create timeline event for child recipe ${childRecipe.slug}:`, error);
+ }
+
+ if (
+ newTimelineEvent.value.timestamp
+ && (!childRecipe.lastMade || newTimelineEvent.value.timestamp > childRecipe.lastMade)
+ ) {
+ try {
+ await userApi.recipes.updateLastMade(childRecipe.slug || "", newTimelineEvent.value.timestamp);
+ }
+ catch (error) {
+ console.error(`Failed to update last made date for child recipe ${childRecipe.slug}:`, error);
+ }
+ }
+ }
+
// update the image, if provided
let imageError = false;
if (newTimelineEventImage.value) {
@@ -268,7 +337,6 @@ async function createTimelineEvent() {
console.error("Failed to upload image for timeline event:", error);
}
}
-
if (imageError) {
alert.error(i18n.t("recipe.added-to-timeline-but-failed-to-add-image"));
}
diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePage.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePage.vue
index f44f05445..05cdaba56 100644
--- a/frontend/components/Domain/Recipe/RecipePage/RecipePage.vue
+++ b/frontend/components/Domain/Recipe/RecipePage/RecipePage.vue
@@ -290,10 +290,13 @@ watch(isParsing, () => {
*/
async function saveRecipe() {
- const { data } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
- setMode(PageMode.VIEW);
+ const { data, error } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
+ if (!error) {
+ setMode(PageMode.VIEW);
+ }
if (data?.slug) {
router.push(`/g/${groupSlug.value}/r/` + data.slug);
+ recipe.value = data as NoUndefinedField;
}
}
diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageIngredientEditor.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageIngredientEditor.vue
index 198d59ffd..151c91a80 100644
--- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageIngredientEditor.vue
+++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageIngredientEditor.vue
@@ -30,6 +30,7 @@
v-for="(ingredient, index) in recipe.recipeIngredient"
:key="ingredient.referenceId"
v-model="recipe.recipeIngredient[index]"
+ :is-recipe="ingredientIsRecipe(ingredient)"
enable-drag-handle
enable-context-menu
class="list-group-item"
@@ -69,15 +70,59 @@
{{ parserToolTip }}
-
- {{ $t("general.add") }}
-
+
+
+
+
+ {{ $globals.icons.createAlt }}
+
+ {{ $t('general.add') || 'Add Food' }}
+
+
+
+
+
+ {{ $globals.icons.chevronDown }}
+
+
+
+
+
+
+
+
+
@@ -85,16 +130,18 @@
+
+
diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageParseDialog.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageParseDialog.vue
index 19b0c11a0..6c11f59ed 100644
--- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageParseDialog.vue
+++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageParseDialog.vue
@@ -268,6 +268,11 @@ const state = reactive({
function shouldReview(ing: ParsedIngredient): boolean {
console.debug(`Checking if ingredient needs review (input="${ing.input})":`, ing);
+ if (ing.ingredient.referencedRecipe) {
+ console.debug("No review needed for sub-recipe ingredient");
+ return false;
+ }
+
if ((ing.confidence?.average || 0) < confidenceThreshold) {
console.debug("Needs review due to low confidence:", ing.confidence?.average);
return true;
@@ -364,12 +369,21 @@ async function parseIngredients() {
}
state.loading.parser = true;
try {
- const ingsAsString = props.ingredients.map(ing => parseIngredientText(ing, 1, false) ?? "");
+ const ingsAsString = props.ingredients
+ .filter(ing => !ing.referencedRecipe)
+ .map(ing => parseIngredientText(ing, 1, false) ?? "");
const { data, error } = await api.recipes.parseIngredients(parser.value, ingsAsString);
if (error || !data) {
throw new Error("Failed to parse ingredients");
}
parsedIngs.value = data;
+ const parsed = data ?? [];
+ const recipeRefs = props.ingredients.filter(ing => ing.referencedRecipe).map(ing => ({
+ input: ing.note || "",
+ confidence: {},
+ ingredient: ing,
+ }));
+ parsedIngs.value = [...parsed, ...recipeRefs];
state.currentParsedIndex = -1;
state.allReviewed = false;
createdUnits.clear();
diff --git a/frontend/components/Domain/Recipe/RecipePrintView.vue b/frontend/components/Domain/Recipe/RecipePrintView.vue
index e6c39e245..636517079 100644
--- a/frontend/components/Domain/Recipe/RecipePrintView.vue
+++ b/frontend/components/Domain/Recipe/RecipePrintView.vue
@@ -262,32 +262,55 @@ const ingredientSections = computed(() => {
if (!props.recipe.recipeIngredient) {
return [];
}
-
- return props.recipe.recipeIngredient.reduce((sections, ingredient) => {
- // if title append new section to the end of the array
- if (ingredient.title) {
- sections.push({
- sectionName: ingredient.title,
- ingredients: [ingredient],
- });
-
- return sections;
+ const addIngredientsToSections = (ingredients: RecipeIngredient[], sections: IngredientSection[], title: string | null) => {
+ // If title is set, ensure the section exists before adding ingredients
+ let section: IngredientSection | undefined;
+ if (title) {
+ section = sections.find(sec => sec.sectionName === title);
+ if (!section) {
+ section = { sectionName: title, ingredients: [] };
+ sections.push(section);
+ }
}
- // append new section if first
- if (sections.length === 0) {
- sections.push({
- sectionName: "",
- ingredients: [ingredient],
- });
+ ingredients.forEach((ingredient) => {
+ if (preferences.value.expandChildRecipes && ingredient.referencedRecipe?.recipeIngredient?.length) {
+ // Recursively add to the section for this referenced recipe
+ addIngredientsToSections(
+ ingredient.referencedRecipe.recipeIngredient,
+ sections,
+ "",
+ );
+ }
+ else {
+ const sectionName = title || ingredient.title || "";
+ if (sectionName) {
+ let sec = sections.find(sec => sec.sectionName === sectionName);
+ if (!sec) {
+ sec = { sectionName, ingredients: [] };
+ sections.push(sec);
+ }
+ ingredient.title = sectionName;
+ sec.ingredients.push(ingredient);
+ }
+ else {
+ if (sections.length === 0) {
+ sections.push({
+ sectionName: "",
+ ingredients: [ingredient],
+ });
+ }
+ else {
+ sections[sections.length - 1].ingredients.push(ingredient);
+ }
+ }
+ }
+ });
+ };
- return sections;
- }
-
- // otherwise add ingredient to last section in the array
- sections[sections.length - 1].ingredients.push(ingredient);
- return sections;
- }, [] as IngredientSection[]);
+ const sections: IngredientSection[] = [];
+ addIngredientsToSections(props.recipe.recipeIngredient, sections, null);
+ return sections;
});
// Group instructions by section so we can style them independently
diff --git a/frontend/composables/recipes/use-recipe-ingredients.ts b/frontend/composables/recipes/use-recipe-ingredients.ts
index 00ee9f55b..a03562b17 100644
--- a/frontend/composables/recipes/use-recipe-ingredients.ts
+++ b/frontend/composables/recipes/use-recipe-ingredients.ts
@@ -1,6 +1,6 @@
import DOMPurify from "isomorphic-dompurify";
import { useFraction } from "./use-fraction";
-import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, RecipeIngredient } from "~/lib/api/types/recipe";
+import type { CreateIngredientFood, CreateIngredientUnit, IngredientFood, IngredientUnit, Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
const { frac } = useFraction();
@@ -36,8 +36,28 @@ function useUnitName(unit: CreateIngredientUnit | IngredientUnit | undefined, us
return returnVal;
}
-export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true) {
- const { quantity, food, unit, note, title } = ingredient;
+function useRecipeLink(recipe: Recipe | undefined, groupSlug: string | undefined): string | undefined {
+ if (!(recipe && recipe.slug && recipe.name && groupSlug)) {
+ return undefined;
+ }
+
+ return `${recipe.name}`;
+}
+
+type ParsedIngredientText = {
+ quantity?: string;
+ unit?: string;
+ name?: string;
+ note?: string;
+
+ /**
+ * If the ingredient is a linked recipe, an HTML link to the referenced recipe, otherwise undefined.
+ */
+ recipeLink?: string;
+};
+
+export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, includeFormating = true, groupSlug?: string): ParsedIngredientText {
+ const { quantity, food, unit, note, referencedRecipe } = ingredient;
const usePluralUnit = quantity !== undefined && ((quantity || 0) * scale > 1 || (quantity || 0) * scale === 0);
const usePluralFood = (!quantity) || quantity * scale > 1;
@@ -62,15 +82,16 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1,
}
}
+ // TODO: Add support for sub-recipes here?
const unitName = useUnitName(unit || undefined, usePluralUnit);
- const foodName = useFoodName(food || undefined, usePluralFood);
+ const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood);
return {
- title: title ? sanitizeIngredientHTML(title) : undefined,
quantity: returnQty ? sanitizeIngredientHTML(returnQty) : undefined,
unit: unitName && quantity ? sanitizeIngredientHTML(unitName) : undefined,
- name: foodName ? sanitizeIngredientHTML(foodName) : undefined,
+ name: ingName ? sanitizeIngredientHTML(ingName) : undefined,
note: note ? sanitizeIngredientHTML(note) : undefined,
+ recipeLink: useRecipeLink(referencedRecipe || undefined, groupSlug),
};
}
diff --git a/frontend/composables/use-users/preferences.ts b/frontend/composables/use-users/preferences.ts
index 19896eaca..46f4752b0 100644
--- a/frontend/composables/use-users/preferences.ts
+++ b/frontend/composables/use-users/preferences.ts
@@ -8,6 +8,7 @@ export interface UserPrintPreferences {
showDescription: boolean;
showNotes: boolean;
showNutrition: boolean;
+ expandChildRecipes: boolean;
}
export interface UserSearchQuery {
@@ -91,6 +92,7 @@ export function useUserPrintPreferences(): Ref {
imagePosition: "left",
showDescription: true,
showNotes: true,
+ expandChildRecipes: false,
},
{ mergeDefaults: true },
// we cast to a Ref because by default it will return an optional type ref
diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json
index 815dcbd34..155f500b2 100644
--- a/frontend/lang/messages/en-US.json
+++ b/frontend/lang/messages/en-US.json
@@ -448,7 +448,9 @@
"split-by-numbered-line-description": "Attempts to split a paragraph by matching '1)' or '1.' patterns",
"import-by-url": "Import a recipe by URL",
"create-manually": "Create a recipe manually",
- "make-recipe-image": "Make this the recipe image"
+ "make-recipe-image": "Make this the recipe image",
+ "add-food": "Add Food",
+ "add-recipe": "Add Recipe"
},
"page": {
"404-page-not-found": "404 Page not found",
@@ -590,6 +592,7 @@
"made-this": "I Made This",
"how-did-it-turn-out": "How did it turn out?",
"user-made-this": "{user} made this",
+ "made-for-recipe": "Made for {recipe}",
"added-to-timeline": "Added to timeline",
"failed-to-add-to-timeline": "Failed to add to timeline",
"failed-to-update-recipe": "Failed to update recipe",
@@ -691,7 +694,10 @@
"upload-images": "Upload images",
"upload-more-images": "Upload more images",
"set-as-cover-image": "Set as recipe cover image",
- "cover-image": "Cover image"
+ "cover-image": "Cover image",
+ "include-linked-recipes": "Include Linked Recipes",
+ "include-linked-recipe-ingredients": "Include Linked Recipe Ingredients",
+ "toggle-recipe": "Toggle Recipe"
},
"recipe-finder": {
"recipe-finder": "Recipe Finder",
diff --git a/frontend/lib/api/types/household.ts b/frontend/lib/api/types/household.ts
index 3148a709f..9ffc96d45 100644
--- a/frontend/lib/api/types/household.ts
+++ b/frontend/lib/api/types/household.ts
@@ -309,6 +309,7 @@ export interface RecipeIngredient {
quantity?: number | null;
unit?: IngredientUnit | CreateIngredientUnit | null;
food?: IngredientFood | CreateIngredientFood | null;
+ referencedRecipe?: Recipe | null;
note?: string | null;
display?: string;
title?: string | null;
@@ -396,6 +397,129 @@ export interface CreateIngredientFoodAlias {
name: string;
[k: string]: unknown;
}
+export interface Recipe {
+ id?: string | null;
+ userId?: string;
+ householdId?: string;
+ groupId?: string;
+ name?: string | null;
+ slug?: string;
+ image?: unknown;
+ recipeServings?: number;
+ recipeYieldQuantity?: number;
+ recipeYield?: string | null;
+ totalTime?: string | null;
+ prepTime?: string | null;
+ cookTime?: string | null;
+ performTime?: string | null;
+ description?: string | null;
+ recipeCategory?: RecipeCategory[] | null;
+ tags?: RecipeTag[] | null;
+ tools?: RecipeTool[];
+ rating?: number | null;
+ orgURL?: string | null;
+ dateAdded?: string | null;
+ dateUpdated?: string | null;
+ createdAt?: string | null;
+ updatedAt?: string | null;
+ lastMade?: string | null;
+ recipeIngredient?: RecipeIngredient[];
+ recipeInstructions?: RecipeStep[] | null;
+ nutrition?: Nutrition | null;
+ settings?: RecipeSettings | null;
+ assets?: RecipeAsset[] | null;
+ notes?: RecipeNote[] | null;
+ extras?: {
+ [k: string]: unknown;
+ } | null;
+ comments?: RecipeCommentOut[] | null;
+ [k: string]: unknown;
+}
+export interface RecipeCategory {
+ id?: string | null;
+ groupId?: string | null;
+ name: string;
+ slug: string;
+ [k: string]: unknown;
+}
+export interface RecipeTag {
+ id?: string | null;
+ groupId?: string | null;
+ name: string;
+ slug: string;
+ [k: string]: unknown;
+}
+export interface RecipeTool {
+ id: string;
+ groupId?: string | null;
+ name: string;
+ slug: string;
+ householdsWithTool?: string[];
+ [k: string]: unknown;
+}
+export interface RecipeStep {
+ id?: string | null;
+ title?: string | null;
+ summary?: string | null;
+ text: string;
+ ingredientReferences?: IngredientReferences[];
+ [k: string]: unknown;
+}
+export interface IngredientReferences {
+ referenceId?: string | null;
+ [k: string]: unknown;
+}
+export interface Nutrition {
+ calories?: string | null;
+ carbohydrateContent?: string | null;
+ cholesterolContent?: string | null;
+ fatContent?: string | null;
+ fiberContent?: string | null;
+ proteinContent?: string | null;
+ saturatedFatContent?: string | null;
+ sodiumContent?: string | null;
+ sugarContent?: string | null;
+ transFatContent?: string | null;
+ unsaturatedFatContent?: string | null;
+ [k: string]: unknown;
+}
+export interface RecipeSettings {
+ public?: boolean;
+ showNutrition?: boolean;
+ showAssets?: boolean;
+ landscapeView?: boolean;
+ disableComments?: boolean;
+ locked?: boolean;
+ [k: string]: unknown;
+}
+export interface RecipeAsset {
+ name: string;
+ icon: string;
+ fileName?: string | null;
+ [k: string]: unknown;
+}
+export interface RecipeNote {
+ title: string;
+ text: string;
+ [k: string]: unknown;
+}
+export interface RecipeCommentOut {
+ recipeId: string;
+ text: string;
+ id: string;
+ createdAt: string;
+ updatedAt: string;
+ userId: string;
+ user: UserBase;
+ [k: string]: unknown;
+}
+export interface UserBase {
+ id: string;
+ username?: string | null;
+ admin: boolean;
+ fullName?: string | null;
+ [k: string]: unknown;
+}
export interface ShoppingListAddRecipeParamsBulk {
recipeIncrementQuantity?: number;
recipeIngredients?: RecipeIngredient[] | null;
@@ -413,6 +537,7 @@ export interface ShoppingListItemBase {
quantity?: number;
unit?: IngredientUnit | CreateIngredientUnit | null;
food?: IngredientFood | CreateIngredientFood | null;
+ referencedRecipe?: Recipe | null;
note?: string | null;
display?: string;
shoppingListId: string;
@@ -429,6 +554,7 @@ export interface ShoppingListItemCreate {
quantity?: number;
unit?: IngredientUnit | CreateIngredientUnit | null;
food?: IngredientFood | CreateIngredientFood | null;
+ referencedRecipe?: Recipe | null;
note?: string | null;
display?: string;
shoppingListId: string;
@@ -453,6 +579,7 @@ export interface ShoppingListItemOut {
quantity?: number;
unit?: IngredientUnit | null;
food?: IngredientFood | null;
+ referencedRecipe?: Recipe | null;
note?: string | null;
display?: string;
shoppingListId: string;
@@ -492,6 +619,7 @@ export interface ShoppingListItemUpdate {
quantity?: number;
unit?: IngredientUnit | CreateIngredientUnit | null;
food?: IngredientFood | CreateIngredientFood | null;
+ referencedRecipe?: Recipe | null;
note?: string | null;
display?: string;
shoppingListId: string;
@@ -509,6 +637,7 @@ export interface ShoppingListItemUpdateBulk {
quantity?: number;
unit?: IngredientUnit | CreateIngredientUnit | null;
food?: IngredientFood | CreateIngredientFood | null;
+ referencedRecipe?: Recipe | null;
note?: string | null;
display?: string;
shoppingListId: string;
@@ -595,28 +724,6 @@ export interface RecipeSummary {
updatedAt?: string | null;
lastMade?: string | null;
}
-export interface RecipeCategory {
- id?: string | null;
- groupId?: string | null;
- name: string;
- slug: string;
- [k: string]: unknown;
-}
-export interface RecipeTag {
- id?: string | null;
- groupId?: string | null;
- name: string;
- slug: string;
- [k: string]: unknown;
-}
-export interface RecipeTool {
- id: string;
- groupId?: string | null;
- name: string;
- slug: string;
- householdsWithTool?: string[];
- [k: string]: unknown;
-}
export interface ShoppingListRemoveRecipeParams {
recipeDecrementQuantity?: number;
}
@@ -682,6 +789,7 @@ export interface RecipeIngredientBase {
quantity?: number | null;
unit?: IngredientUnit | CreateIngredientUnit | null;
food?: IngredientFood | CreateIngredientFood | null;
+ referencedRecipe?: Recipe | null;
note?: string | null;
display?: string;
}
diff --git a/frontend/lib/api/types/recipe.ts b/frontend/lib/api/types/recipe.ts
index 51491de3b..cc045cdbc 100644
--- a/frontend/lib/api/types/recipe.ts
+++ b/frontend/lib/api/types/recipe.ts
@@ -214,6 +214,7 @@ export interface RecipeIngredient {
quantity?: number | null;
unit?: IngredientUnit | CreateIngredientUnit | null;
food?: IngredientFood | CreateIngredientFood | null;
+ referencedRecipe?: Recipe | null;
note?: string | null;
display?: string;
title?: string | null;
@@ -349,6 +350,7 @@ export interface RecipeIngredientBase {
quantity?: number | null;
unit?: IngredientUnit | CreateIngredientUnit | null;
food?: IngredientFood | CreateIngredientFood | null;
+ referencedRecipe?: Recipe | null;
note?: string | null;
display?: string;
}
diff --git a/mealie/alembic/versions/2025-09-10-19.21.48_1d9a002d7234_add_referenced_recipe_to_ingredients.py b/mealie/alembic/versions/2025-09-10-19.21.48_1d9a002d7234_add_referenced_recipe_to_ingredients.py
new file mode 100644
index 000000000..0e5e64eda
--- /dev/null
+++ b/mealie/alembic/versions/2025-09-10-19.21.48_1d9a002d7234_add_referenced_recipe_to_ingredients.py
@@ -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 ###
diff --git a/mealie/core/exceptions.py b/mealie/core/exceptions.py
index c51d5af63..2c2684bc5 100644
--- a/mealie/core/exceptions.py
+++ b/mealie/core/exceptions.py
@@ -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"),
}
diff --git a/mealie/db/models/recipe/ingredient.py b/mealie/db/models/recipe/ingredient.py
index 5d23d9ad9..f65830953 100644
--- a/mealie/db/models/recipe/ingredient.py
+++ b/mealie/db/models/recipe/ingredient.py
@@ -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)
diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py
index 5ffebed67..327b8cd6e 100644
--- a/mealie/db/models/recipe/recipe.py
+++ b/mealie/db/models/recipe/recipe.py
@@ -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",
diff --git a/mealie/lang/messages/en-US.json b/mealie/lang/messages/en-US.json
index cf38657e0..9a815c35b 100644
--- a/mealie/lang/messages/en-US.json
+++ b/mealie/lang/messages/en-US.json
@@ -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",
diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py
index a65ac305f..b5d9f037a 100644
--- a/mealie/routes/recipe/recipe_crud_routes.py
+++ b/mealie/routes/recipe/recipe_crud_routes.py
@@ -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(
diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py
index 31aa51f43..3b74fbfe0 100644
--- a/mealie/schema/recipe/recipe_ingredient.py
+++ b/mealie/schema/recipe/recipe_ingredient.py
@@ -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
diff --git a/mealie/services/household_services/shopping_lists.py b/mealie/services/household_services/shopping_lists.py
index 22b957a11..4cdb6abf1 100644
--- a/mealie/services/household_services/shopping_lists.py
+++ b/mealie/services/household_services/shopping_lists.py
@@ -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
diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py
index 43782725f..21b91357e 100644
--- a/mealie/services/recipe/recipe_service.py
+++ b/mealie/services/recipe/recipe_service.py
@@ -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:
diff --git a/tests/integration_tests/user_household_tests/test_group_shopping_lists.py b/tests/integration_tests/user_household_tests/test_group_shopping_lists.py
index 9fe6cb8d4..fdc89254b 100644
--- a/tests/integration_tests/user_household_tests/test_group_shopping_lists.py
+++ b/tests/integration_tests/user_household_tests/test_group_shopping_lists.py
@@ -11,6 +11,7 @@ from mealie.schema.household.group_shopping_list import (
ShoppingListOut,
)
from mealie.schema.recipe.recipe import Recipe
+from mealie.schema.recipe.recipe_ingredient import RecipeIngredient, SaveIngredientFood
from tests import utils
from tests.utils import api_routes
from tests.utils.assertion_helpers import assert_deserialize
@@ -245,6 +246,121 @@ def test_shopping_lists_add_recipes(
assert refs_by_id[str(recipe.id)]["recipeQuantity"] == 2
+def test_shopping_lists_add_nested_recipe_ingredients(
+ api_client: TestClient,
+ unique_user: TestUser,
+ shopping_lists: list[ShoppingListOut],
+):
+ """Test that adding a recipe with nested recipe ingredients flattens all ingredients (a -> b -> c)."""
+ shopping_list = random.choice(shopping_lists)
+ database = unique_user.repos
+
+ # Create three food items for the base recipes
+ food_c = database.ingredient_foods.create(
+ SaveIngredientFood(
+ name=random_string(10),
+ group_id=unique_user.group_id,
+ )
+ )
+
+ food_b = database.ingredient_foods.create(
+ SaveIngredientFood(
+ name=random_string(10),
+ group_id=unique_user.group_id,
+ )
+ )
+
+ food_a = database.ingredient_foods.create(
+ SaveIngredientFood(
+ name=random_string(10),
+ group_id=unique_user.group_id,
+ )
+ )
+
+ # Create recipe_c with a single food ingredient (base recipe)
+ recipe_c: Recipe = database.recipes.create(
+ Recipe(
+ name=random_string(10),
+ user_id=unique_user.user_id,
+ group_id=unique_user.group_id,
+ recipe_ingredient=[
+ RecipeIngredient(note=f"ingredient from recipe c - {food_c.name}", food=food_c, quantity=1),
+ ],
+ )
+ )
+
+ # Create recipe_b with its own food ingredient and a reference to recipe_c (c -> b)
+ recipe_b: Recipe = database.recipes.create(
+ Recipe(
+ name=random_string(10),
+ user_id=unique_user.user_id,
+ group_id=unique_user.group_id,
+ recipe_ingredient=[
+ RecipeIngredient(note=f"ingredient from recipe b - {food_b.name}", food=food_b, quantity=2),
+ RecipeIngredient(note="nested recipe c", referenced_recipe=recipe_c),
+ ],
+ )
+ )
+
+ # Create recipe_a with its own food ingredient and a reference to recipe_b (b -> a, creating chain c -> b -> a)
+ recipe_a: Recipe = database.recipes.create(
+ Recipe(
+ name=random_string(10),
+ user_id=unique_user.user_id,
+ group_id=unique_user.group_id,
+ recipe_ingredient=[
+ RecipeIngredient(note=f"ingredient from recipe a - {food_a.name}", food=food_a, quantity=3),
+ RecipeIngredient(note="nested recipe b", referenced_recipe=recipe_b),
+ ],
+ )
+ )
+
+ # Add recipe_a to the shopping list
+ response = api_client.post(
+ api_routes.households_shopping_lists_item_id_recipe(shopping_list.id),
+ json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe_a.id).model_dump()]),
+ headers=unique_user.token,
+ )
+ assert response.status_code == 200
+
+ # Get the shopping list and verify all ingredients from a, b, and c are flattened
+ response = api_client.get(
+ api_routes.households_shopping_lists_item_id(shopping_list.id),
+ headers=unique_user.token,
+ )
+ shopping_list_data = utils.assert_deserialize(response, 200)
+
+ # Should have 3 items: one from recipe_a, one from recipe_b, and one from recipe_c
+ assert len(shopping_list_data["listItems"]) == 3
+
+ # Verify each ingredient is present with the correct quantity
+ found_ingredients = {
+ food_a.name: False,
+ food_b.name: False,
+ food_c.name: False,
+ }
+
+ for item in shopping_list_data["listItems"]:
+ if food_a.name in item["note"]:
+ assert item["quantity"] == 3
+ found_ingredients[food_a.name] = True
+ elif food_b.name in item["note"]:
+ assert item["quantity"] == 2
+ found_ingredients[food_b.name] = True
+ elif food_c.name in item["note"]:
+ assert item["quantity"] == 1
+ found_ingredients[food_c.name] = True
+
+ # Ensure all ingredients were found
+ assert all(found_ingredients.values()), f"Missing ingredients: {found_ingredients}"
+
+ # Verify recipe reference
+ refs = shopping_list_data["recipeReferences"]
+ assert len(refs) == 1
+ assert refs[0]["recipeId"] == str(recipe_a.id)
+ assert refs[0]["recipeQuantity"] == 1
+
+
@pytest.mark.parametrize("is_private_household", [True, False])
@pytest.mark.parametrize("household_lock_recipe_edits", [True, False])
def test_shopping_lists_add_cross_household_recipe(
diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py
index b36efb6de..99d41f91f 100644
--- a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py
+++ b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py
@@ -23,6 +23,7 @@ from mealie.pkgs.safehttp.transport import AsyncSafeTransport
from mealie.schema.cookbook.cookbook import SaveCookBook
from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipeSummary, RecipeTag
from mealie.schema.recipe.recipe_category import CategorySave, TagSave
+from mealie.schema.recipe.recipe_ingredient import RecipeIngredient, SaveIngredientFood
from mealie.schema.recipe.recipe_notes import RecipeNote
from mealie.schema.recipe.recipe_tool import RecipeToolSave
from mealie.services.recipe.recipe_data_service import RecipeDataService
@@ -512,6 +513,251 @@ def test_update_many(api_client: TestClient, unique_user: TestUser, use_patch: b
assert get_response.json()["slug"] == updated_recipe_data["slug"]
+def test_recipe_recursion_valid_linear_chain(api_client: TestClient, unique_user: TestUser):
+ """Test that valid deep nesting without cycles is allowed (a -> b -> c)."""
+ database = unique_user.repos
+
+ food = database.ingredient_foods.create(
+ SaveIngredientFood(
+ name=random_string(10),
+ group_id=unique_user.group_id,
+ )
+ )
+
+ # Create recipe_c with just a food ingredient (base recipe)
+ recipe_c: Recipe = database.recipes.create(
+ Recipe(
+ name=random_string(10),
+ user_id=unique_user.user_id,
+ group_id=unique_user.group_id,
+ recipe_ingredient=[
+ RecipeIngredient(note="", food=food),
+ ],
+ )
+ )
+
+ # Create recipe_b that references recipe_c (c -> b)
+ recipe_b = database.recipes.create(
+ Recipe(
+ name=random_string(10),
+ user_id=unique_user.user_id,
+ group_id=unique_user.group_id,
+ recipe_ingredient=[
+ RecipeIngredient(note="", referenced_recipe=recipe_c),
+ ],
+ )
+ )
+
+ # Update recipe_a to reference recipe_b (b -> a, creating chain c -> b -> a)
+ recipe_a: Recipe = database.recipes.create(
+ Recipe(
+ name=random_string(10),
+ user_id=unique_user.user_id,
+ group_id=unique_user.group_id,
+ recipe_ingredient=[
+ RecipeIngredient(note="", food=food),
+ ],
+ )
+ )
+
+ recipe_url = api_routes.recipes_slug(recipe_a.slug)
+ response = api_client.get(recipe_url, headers=unique_user.token)
+ assert response.status_code == 200
+ recipe_data = json.loads(response.text)
+
+ recipe_data["recipeIngredient"].append(
+ {
+ "note": "",
+ "referencedRecipe": {"id": str(recipe_b.id)},
+ }
+ )
+ response = api_client.put(recipe_url, json=recipe_data, headers=unique_user.token)
+ assert response.status_code == 200
+
+
+def test_recipe_recursion_cycle_two_level(api_client: TestClient, unique_user: TestUser):
+ """Test that two-level cycles (a -> b -> a) are detected and rejected."""
+ database = unique_user.repos
+
+ food = database.ingredient_foods.create(
+ SaveIngredientFood(
+ name=random_string(10),
+ group_id=unique_user.group_id,
+ )
+ )
+
+ # Create recipe_a
+ recipe_a: Recipe = database.recipes.create(
+ Recipe(
+ name=random_string(10),
+ user_id=unique_user.user_id,
+ group_id=unique_user.group_id,
+ recipe_ingredient=[
+ RecipeIngredient(note="", food=food),
+ ],
+ )
+ )
+
+ # Create recipe_b that references recipe_a (a -> b)
+ recipe_b = database.recipes.create(
+ Recipe(
+ name=random_string(10),
+ user_id=unique_user.user_id,
+ group_id=unique_user.group_id,
+ recipe_ingredient=[
+ RecipeIngredient(note="", referenced_recipe=recipe_a),
+ ],
+ )
+ )
+
+ # Try to update recipe_a to reference recipe_b, creating a cycle (b -> a)
+ recipe_url = api_routes.recipes_slug(recipe_a.slug)
+ response = api_client.get(recipe_url, headers=unique_user.token)
+ assert response.status_code == 200
+ recipe_data = json.loads(response.text)
+
+ recipe_data["recipeIngredient"].append(
+ {
+ "note": "",
+ "referencedRecipe": {"id": str(recipe_b.id)},
+ }
+ )
+ response = api_client.put(recipe_url, json=recipe_data, headers=unique_user.token)
+ assert response.status_code == 400
+ assert "cannot reference itself" in response.text.lower()
+
+
+def test_recipe_recursion_cycle_three_level(api_client: TestClient, unique_user: TestUser):
+ """Test that three-level cycles (a -> b -> c -> a) are detected and rejected."""
+ database = unique_user.repos
+
+ food = database.ingredient_foods.create(
+ SaveIngredientFood(
+ name=random_string(10),
+ group_id=unique_user.group_id,
+ )
+ )
+
+ # Create recipe_a
+ recipe_a: Recipe = database.recipes.create(
+ Recipe(
+ name=random_string(10),
+ user_id=unique_user.user_id,
+ group_id=unique_user.group_id,
+ recipe_ingredient=[
+ RecipeIngredient(note="", food=food),
+ ],
+ )
+ )
+
+ # Create recipe_b that references recipe_a (a -> b)
+ recipe_b = database.recipes.create(
+ Recipe(
+ name=random_string(10),
+ user_id=unique_user.user_id,
+ group_id=unique_user.group_id,
+ recipe_ingredient=[
+ RecipeIngredient(note="", referenced_recipe=recipe_a),
+ ],
+ )
+ )
+
+ # Create recipe_c that references recipe_b (b -> c, creating chain a -> b -> c)
+ recipe_c = database.recipes.create(
+ Recipe(
+ name=random_string(10),
+ user_id=unique_user.user_id,
+ group_id=unique_user.group_id,
+ recipe_ingredient=[
+ RecipeIngredient(note="", referenced_recipe=recipe_b),
+ ],
+ )
+ )
+
+ # Try to update recipe_a to reference recipe_c, creating a cycle (c -> a)
+ recipe_url = api_routes.recipes_slug(recipe_a.slug)
+ response = api_client.get(recipe_url, headers=unique_user.token)
+ assert response.status_code == 200
+ recipe_data = json.loads(response.text)
+
+ recipe_data["recipeIngredient"].append(
+ {
+ "note": "",
+ "referencedRecipe": {"id": str(recipe_c.id)},
+ }
+ )
+ response = api_client.put(recipe_url, json=recipe_data, headers=unique_user.token)
+ assert response.status_code == 400
+ assert "cannot reference itself" in response.text.lower()
+
+
+def test_recipe_reference_deleted(api_client: TestClient, unique_user: TestUser):
+ """Test that when a referenced recipe is deleted, the parent recipe remains intact."""
+ database = unique_user.repos
+
+ food = database.ingredient_foods.create(
+ SaveIngredientFood(
+ name=random_string(10),
+ group_id=unique_user.group_id,
+ )
+ )
+
+ # Create recipe_b
+ recipe_b: Recipe = database.recipes.create(
+ Recipe(
+ name=random_string(10),
+ user_id=unique_user.user_id,
+ group_id=unique_user.group_id,
+ recipe_ingredient=[
+ RecipeIngredient(note="", food=food),
+ ],
+ )
+ )
+
+ # Create recipe_a that references recipe_b
+ recipe_a = database.recipes.create(
+ Recipe(
+ name=random_string(10),
+ user_id=unique_user.user_id,
+ group_id=unique_user.group_id,
+ recipe_ingredient=[
+ RecipeIngredient(note="ingredient 1", referenced_recipe=recipe_b),
+ RecipeIngredient(note="ingredient 2", food=food),
+ ],
+ )
+ )
+
+ # Verify recipe_a has the reference to recipe_b
+ recipe_a_url = api_routes.recipes_slug(recipe_a.slug)
+ response = api_client.get(recipe_a_url, headers=unique_user.token)
+ assert response.status_code == 200
+ recipe_a_data = json.loads(response.text)
+ assert len(recipe_a_data["recipeIngredient"]) == 2
+ assert recipe_a_data["recipeIngredient"][0]["referencedRecipe"] is not None
+ assert recipe_a_data["recipeIngredient"][0]["referencedRecipe"]["id"] == str(recipe_b.id)
+
+ # Delete recipe_b
+ recipe_b_url = api_routes.recipes_slug(recipe_b.slug)
+ response = api_client.delete(recipe_b_url, headers=unique_user.token)
+ assert response.status_code == 200
+
+ # Verify recipe_b is deleted
+ response = api_client.get(recipe_b_url, headers=unique_user.token)
+ assert response.status_code == 404
+
+ # Verify recipe_a still exists and can be retrieved
+ response = api_client.get(recipe_a_url, headers=unique_user.token)
+ assert response.status_code == 200
+ recipe_a_data = json.loads(response.text)
+
+ # The ingredient with the deleted reference should still exist but with no valid reference
+ assert len(recipe_a_data["recipeIngredient"]) == 2
+ assert recipe_a_data["recipeIngredient"][0]["note"] == "ingredient 1"
+ assert recipe_a_data["recipeIngredient"][1]["note"] == "ingredient 2"
+ # The referenced recipe should be None or not present since it was deleted
+ assert recipe_a_data["recipeIngredient"][0]["referencedRecipe"] is None
+
+
def test_duplicate(api_client: TestClient, unique_user: TestUser):
recipe_data = recipe_test_data[0]