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

File diff suppressed because one or more lines are too long

View File

@@ -139,7 +139,7 @@
color="secondary"
density="compact"
/>
<div :key="`${ingredientData.ingredient.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
<div :key="`${ingredientData.ingredient?.quantity || 'no-qty'}-${i}`" class="pa-auto my-auto">
<RecipeIngredientListItem
:ingredient="ingredientData.ingredient"
:scale="recipeSection.recipeScale"
@@ -287,12 +287,35 @@ async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
continue;
}
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
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;

View File

@@ -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,
});
</script>

View File

@@ -69,7 +69,14 @@
:label="$t('recipe.nutrition')"
/>
</v-row>
<v-row no-gutters />
<v-row no-gutters>
<v-switch
v-model="preferences.expandChildRecipes"
hide-details
color="primary"
:label="$t('recipe.include-linked-recipe-ingredients')"
/>
</v-row>
</v-col>
</v-row>
</v-container>

View File

@@ -41,6 +41,7 @@
</v-text-field>
</v-col>
<v-col
v-if="!state.isRecipe"
sm="12"
md="3"
cols="12"
@@ -97,6 +98,7 @@
<!-- Foods Input -->
<v-col
v-if="!state.isRecipe"
m="12"
md="3"
cols="12"
@@ -151,6 +153,33 @@
</template>
</v-autocomplete>
</v-col>
<!-- Recipe Input -->
<v-col
v-if="state.isRecipe"
m="12"
md="6"
cols="12"
class=""
>
<v-autocomplete
ref="search.query"
v-model="model.referencedRecipe"
v-model:search="search.query.value"
auto-select-first
hide-details
density="compact"
variant="solo"
return-object
:items="search.data.value || []"
item-title="name"
class="mx-1 py-0"
placeholder="Choose Recipe"
clearable
label="Recipe"
>
<template #prepend />
</v-autocomplete>
</v-col>
<v-col
sm="12"
md=""
@@ -173,6 +202,7 @@
class="my-auto d-flex"
:buttons="btns"
@toggle-section="toggleTitle"
@toggle-subrecipe="toggleIsRecipe"
@insert-above="$emit('insert-above')"
@insert-below="$emit('insert-below')"
@delete="$emit('delete')"
@@ -195,6 +225,8 @@ import { useI18n } from "vue-i18n";
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
import { useNuxtApp } from "#app";
import type { RecipeIngredient } from "~/lib/api/types/recipe";
import { usePublicExploreApi, useUserApi } from "~/composables/api";
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
// defineModel replaces modelValue prop
const model = defineModel<RecipeIngredient>({ 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

View File

@@ -13,6 +13,10 @@
class="text-bold d-inline"
:source="parsedIng.note"
/>
<template v-else-if="parsedIng.recipeLink">
<SafeMarkdown v-if="parsedIng.recipeLink" class="text-bold d-inline" :source="parsedIng.recipeLink" />
<SafeMarkdown v-if="parsedIng.note" class="note" :source="parsedIng.note" />
</template>
<template v-else>
<SafeMarkdown
v-if="parsedIng.name"
@@ -39,9 +43,12 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
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());
});
</script>

View File

@@ -20,6 +20,29 @@
persistent-hint
rows="4"
/>
<div v-if="childRecipes?.length">
<v-card-text class="pt-6 pb-0">
{{ $t('recipe.include-linked-recipes') }}
</v-card-text>
<v-list>
<v-list-item
v-for="(childRecipe, i) in childRecipes"
:key="childRecipe.recipeId + i"
density="compact"
class="my-0 py-0"
@click="childRecipe.checked = !childRecipe.checked"
>
<v-checkbox
hide-details
density="compact"
:input-value="childRecipe.checked"
:label="childRecipe.name"
class="my-0 py-0"
color="secondary"
/>
</v-list-item>
</v-list>
</div>
<v-container>
<v-row>
<v-col cols="6">
@@ -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"));
}

View File

@@ -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<Recipe>;
}
}

View File

@@ -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 @@
<span>{{ parserToolTip }}</span>
</v-tooltip>
<RecipeDialogBulkAdd
ref="domBulkAddDialog"
class="mx-1 mb-1"
style="display: none"
@bulk-data="addIngredient"
/>
<BaseButton
class="mb-1"
@click="addIngredient"
>
{{ $t("general.add") }}
</BaseButton>
<div class="d-inline-flex split-button">
<!-- Main button: Add Food -->
<v-btn
color="success"
class="split-main ml-2"
@click="addIngredient"
>
<v-icon start>
{{ $globals.icons.createAlt }}
</v-icon>
{{ $t('general.add') || 'Add Food' }}
</v-btn>
<!-- Dropdown button -->
<v-menu>
<template #activator="{ props }">
<v-btn
color="success"
class="split-dropdown"
v-bind="props"
>
<v-icon>{{ $globals.icons.chevronDown }}</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item
slim
density="comfortable"
:prepend-icon="$globals.icons.foods"
:title="$t('new-recipe.add-food')"
@click="addIngredient"
/>
<v-list-item
slim
density="comfortable"
:prepend-icon="$globals.icons.silverwareForkKnife"
:title="$t('new-recipe.add-recipe')"
@click="addRecipe"
/>
<v-list-item
slim
density="comfortable"
:prepend-icon="$globals.icons.create"
:title="$t('new-recipe.bulk-add')"
@click="showBulkAdd"
/>
</v-list>
</v-menu>
</div>
</div>
</div>
</template>
@@ -85,16 +130,18 @@
<script setup lang="ts">
import { VueDraggable } from "vue-draggable-plus";
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } from "~/lib/api/types/recipe";
import type { Recipe, RecipeIngredient } from "~/lib/api/types/recipe";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
import { usePageState } from "~/composables/recipe-page/shared-state";
import { uuid4 } from "~/composables/use-utils";
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
const ingredientsWithRecipe = new Map<string, boolean>();
const i18n = useI18n();
const drag = ref(false);
const domBulkAddDialog = ref<InstanceType<typeof RecipeDialogBulkAdd> | null>(null);
const { toggleIsParsing } = usePageState(recipe.value.slug);
const hasFoodOrUnit = computed(() => {
@@ -118,6 +165,22 @@ const parserToolTip = computed(() => {
return i18n.t("recipe.parse-ingredients");
});
function showBulkAdd() {
domBulkAddDialog.value?.open();
}
function ingredientIsRecipe(ingredient: RecipeIngredient): boolean {
if (ingredient.referencedRecipe) {
return true;
}
if (ingredient.referenceId) {
return !!ingredientsWithRecipe.get(ingredient.referenceId);
}
return false;
}
function addIngredient(ingredients: Array<string> | null = null) {
if (ingredients?.length) {
const newIngredients = ingredients.map((x) => {
@@ -150,6 +213,41 @@ function addIngredient(ingredients: Array<string> | null = null) {
}
}
function addRecipe(recipes: Array<string> | null = null) {
const refId = uuid4();
ingredientsWithRecipe.set(refId, true);
if (recipes?.length) {
const newRecipes = recipes.map((x) => {
return {
referenceId: refId,
title: "",
note: x,
unit: undefined,
referencedRecipe: undefined,
quantity: 1,
};
});
if (newRecipes) {
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
recipe.value.recipeIngredient.push(...newRecipes);
}
}
else {
recipe.value.recipeIngredient.push({
referenceId: refId,
title: "",
note: "",
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
unit: undefined,
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
referencedRecipe: undefined,
quantity: 1,
});
}
}
function insertNewIngredient(dest: number) {
recipe.value.recipeIngredient.splice(dest, 0, {
referenceId: uuid4(),
@@ -163,3 +261,22 @@ function insertNewIngredient(dest: number) {
});
}
</script>
<style scoped>
.split-button {
border-radius: 4px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.split-main {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.split-dropdown {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
min-width: 30px;
padding-left: 0;
padding-right: 0;
}
</style>

View File

@@ -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();

View File

@@ -262,32 +262,55 @@ const ingredientSections = computed<IngredientSection[]>(() => {
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

View File

@@ -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 `<a href="/g/${groupSlug}/r/${recipe.slug}" target="_blank">${recipe.name}</a>`;
}
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),
};
}

View File

@@ -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<UserPrintPreferences> {
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

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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;
}

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:

View File

@@ -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(

View File

@@ -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]