diff --git a/frontend/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue b/frontend/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue index 6f233b84f..0aae51731 100644 --- a/frontend/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue +++ b/frontend/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue @@ -138,8 +138,8 @@ import RecipeIngredientListItem from "./RecipeIngredientListItem.vue"; import { useUserApi } from "~/composables/api"; import { alert } from "~/composables/use-toast"; import { useShoppingListPreferences } from "~/composables/use-users/preferences"; -import { ShoppingListSummary } from "~/lib/api/types/household"; -import { Recipe, RecipeIngredient } from "~/lib/api/types/recipe"; +import { RecipeIngredient, ShoppingListAddRecipeParamsBulk, ShoppingListSummary } from "~/lib/api/types/household"; +import { Recipe } from "~/lib/api/types/recipe"; export interface RecipeWithScale extends Recipe { scale: number; @@ -342,12 +342,12 @@ export default defineComponent({ } async function addRecipesToList() { - const promises: Promise[] = []; - recipeIngredientSections.value.forEach((section) => { - if (!selectedShoppingList.value) { - return; - } + if (!selectedShoppingList.value) { + return; + } + const recipeData: ShoppingListAddRecipeParamsBulk[] = []; + recipeIngredientSections.value.forEach((section) => { const ingredients: RecipeIngredient[] = []; section.ingredientSections.forEach((ingSection) => { ingSection.ingredients.forEach((ing) => { @@ -361,24 +361,18 @@ export default defineComponent({ return; } - promises.push(api.shopping.lists.addRecipe( - selectedShoppingList.value.id, - section.recipeId, - section.recipeScale, - ingredients, - )); + recipeData.push( + { + recipeId: section.recipeId, + recipeIncrementQuantity: section.recipeScale, + recipeIngredients: ingredients, + } + ); }); - let success = true; - const results = await Promise.allSettled(promises); - results.forEach((result) => { - if (result.status === "rejected") { - success = false; - } - }) - - success ? alert.success(i18n.tc("recipe.successfully-added-to-list")) - : alert.error(i18n.tc("failed-to-add-recipes-to-list")) + const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData); + error ? alert.error(i18n.tc("recipe.failed-to-add-recipes-to-list")) + : alert.success(i18n.tc("recipe.successfully-added-to-list")); state.shoppingListDialog = false; state.shoppingListIngredientDialog = false; diff --git a/frontend/lib/api/types/household.ts b/frontend/lib/api/types/household.ts index e0340a1d3..4291af758 100644 --- a/frontend/lib/api/types/household.ts +++ b/frontend/lib/api/types/household.ts @@ -391,6 +391,11 @@ export interface CreateIngredientFoodAlias { name: string; [k: string]: unknown; } +export interface ShoppingListAddRecipeParamsBulk { + recipeIncrementQuantity?: number; + recipeIngredients?: RecipeIngredient[] | null; + recipeId: string; +} export interface ShoppingListCreate { name?: string | null; extras?: { diff --git a/frontend/lib/api/types/recipe.ts b/frontend/lib/api/types/recipe.ts index 036c43f8c..a526e602e 100644 --- a/frontend/lib/api/types/recipe.ts +++ b/frontend/lib/api/types/recipe.ts @@ -291,6 +291,7 @@ export interface UserBase { id: string; username?: string | null; admin: boolean; + fullName?: string | null; } export interface RecipeCategoryResponse { name: string; diff --git a/frontend/lib/api/user/group-shopping-lists.ts b/frontend/lib/api/user/group-shopping-lists.ts index 807073ef2..1eccb05f7 100644 --- a/frontend/lib/api/user/group-shopping-lists.ts +++ b/frontend/lib/api/user/group-shopping-lists.ts @@ -1,7 +1,7 @@ import { BaseCRUDAPI } from "../base/base-clients"; -import { RecipeIngredient } from "../types/recipe"; import { ApiRequestInstance } from "~/lib/api/types/non-generated"; import { + ShoppingListAddRecipeParamsBulk, ShoppingListCreate, ShoppingListItemCreate, ShoppingListItemOut, @@ -16,7 +16,7 @@ const prefix = "/api"; const routes = { shoppingLists: `${prefix}/households/shopping/lists`, shoppingListsId: (id: string) => `${prefix}/households/shopping/lists/${id}`, - shoppingListIdAddRecipe: (id: string, recipeId: string) => `${prefix}/households/shopping/lists/${id}/recipe/${recipeId}`, + shoppingListIdAddRecipe: (id: string) => `${prefix}/households/shopping/lists/${id}/recipe`, shoppingListIdRemoveRecipe: (id: string, recipeId: string) => `${prefix}/households/shopping/lists/${id}/recipe/${recipeId}/delete`, shoppingListIdUpdateLabelSettings: (id: string) => `${prefix}/households/shopping/lists/${id}/label-settings`, @@ -29,8 +29,8 @@ export class ShoppingListsApi extends BaseCRUDAPI tuple[ShoppingListOut, ShoppingListItemsCollectionOut]: """ - Adds a recipe's ingredients to a list + Adds recipe ingredients to a list Returns a tuple of: - Updated Shopping List - Impacted Shopping List Items """ - items_to_create = self.get_shopping_list_items_from_recipe( - list_id, recipe_id, recipe_increment, recipe_ingredients - ) + items_to_create = [ + item + for recipe in recipe_items + for item in self.get_shopping_list_items_from_recipe( + list_id, recipe.recipe_id, recipe.recipe_increment_quantity, recipe.recipe_ingredients + ) + ] item_changes = self.bulk_create_items(items_to_create) - updated_list = cast(ShoppingListOut, self.shopping_lists.get_one(list_id)) - ref_merged = False - for ref in updated_list.recipe_references: - if ref.recipe_id != recipe_id: - continue + # update list-level recipe references + for recipe in recipe_items: + ref_merged = False + for ref in updated_list.recipe_references: + if ref.recipe_id != recipe.recipe_id: + continue - ref.recipe_quantity += recipe_increment - ref_merged = True - break + ref.recipe_quantity += recipe.recipe_increment_quantity + ref_merged = True + break - if not ref_merged: - updated_list.recipe_references.append( - ShoppingListItemRecipeRefCreate(recipe_id=recipe_id, recipe_quantity=recipe_increment) # type: ignore - ) + if not ref_merged: + updated_list.recipe_references.append( + ShoppingListItemRecipeRefCreate( + recipe_id=recipe.recipe_id, recipe_quantity=recipe.recipe_increment_quantity + ) + ) updated_list = self.shopping_lists.update(updated_list.id, updated_list) return updated_list, item_changes diff --git a/tests/fixtures/fixture_recipe.py b/tests/fixtures/fixture_recipe.py index 90cde0437..7d8f57c25 100644 --- a/tests/fixtures/fixture_recipe.py +++ b/tests/fixtures/fixture_recipe.py @@ -54,6 +54,37 @@ def recipe_ingredient_only(unique_user: TestUser): database.recipes.delete(model.slug) +@fixture(scope="function") +def recipes_ingredient_only(unique_user: TestUser): + database = unique_user.repos + recipes: list[Recipe] = [] + + for _ in range(3): + # Create a recipe + recipe = Recipe( + user_id=unique_user.user_id, + group_id=unique_user.group_id, + name=random_string(10), + recipe_ingredient=[ + RecipeIngredient(note=f"Ingredient 1 {random_string(5)}"), + RecipeIngredient(note=f"Ingredient 2 {random_string(5)}"), + RecipeIngredient(note=f"Ingredient 3 {random_string(5)}"), + RecipeIngredient(note=f"Ingredient 4 {random_string(5)}"), + RecipeIngredient(note=f"Ingredient 5 {random_string(5)}"), + RecipeIngredient(note=f"Ingredient 6 {random_string(5)}"), + ], + ) + + model = database.recipes.create(recipe) + recipes.append(model) + + yield recipes + + with contextlib.suppress(sqlalchemy.exc.NoResultFound): + for recipe in recipes: + database.recipes.delete(recipe.slug) + + @fixture(scope="function") def recipe_categories(unique_user: TestUser) -> Generator[list[CategoryOut], None, None]: database = unique_user.repos diff --git a/tests/integration_tests/user_household_tests/test_group_shopping_list_items.py b/tests/integration_tests/user_household_tests/test_group_shopping_list_items.py index 4685b9d72..83aaf8d85 100644 --- a/tests/integration_tests/user_household_tests/test_group_shopping_list_items.py +++ b/tests/integration_tests/user_household_tests/test_group_shopping_list_items.py @@ -6,7 +6,6 @@ import pytest from fastapi.testclient import TestClient from pydantic import UUID4 -from mealie.repos.repository_factory import AllRepositories from mealie.schema.household.group_shopping_list import ShoppingListItemOut, ShoppingListOut from mealie.schema.recipe.recipe_ingredient import SaveIngredientFood from tests import utils 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 822f07e38..93dc976d5 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 @@ -3,6 +3,7 @@ import random from fastapi.testclient import TestClient from mealie.schema.household.group_shopping_list import ( + ShoppingListAddRecipeParamsBulk, ShoppingListItemOut, ShoppingListItemUpdate, ShoppingListItemUpdateBulk, @@ -171,6 +172,78 @@ def test_shopping_lists_add_recipe( assert refs[0]["recipeQuantity"] == 2 +def test_shopping_lists_add_recipes( + api_client: TestClient, + unique_user: TestUser, + shopping_lists: list[ShoppingListOut], + recipes_ingredient_only: list[Recipe], +): + sample_list = random.choice(shopping_lists) + recipes = recipes_ingredient_only + all_ingredients = [ingredient for recipe in recipes for ingredient in recipe.recipe_ingredient] + + recipes_post_data = utils.jsonify( + [ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump() for recipe in recipes] + ) + response = api_client.post( + api_routes.households_shopping_lists_item_id_recipe(sample_list.id), + json=recipes_post_data, + headers=unique_user.token, + ) + assert response.status_code == 200 + + # get list and verify items against ingredients + response = api_client.get( + api_routes.households_shopping_lists_item_id(sample_list.id), + headers=unique_user.token, + ) + as_json = utils.assert_deserialize(response, 200) + assert len(as_json["listItems"]) == len(all_ingredients) + + known_ingredients = {ingredient.note: ingredient for ingredient in all_ingredients} + for item in as_json["listItems"]: + assert item["note"] in known_ingredients + + ingredient = known_ingredients[item["note"]] + assert item["quantity"] == (ingredient.quantity or 0) + + # check recipe reference was added with quantity 1 + refs = as_json["recipeReferences"] + assert len(refs) == len(recipes) + refs_by_id = {ref["recipeId"]: ref for ref in refs} + for recipe in recipes: + assert str(recipe.id) in refs_by_id + assert refs_by_id[str(recipe.id)]["recipeQuantity"] == 1 + + # add the recipes again and check the resulting items + response = api_client.post( + api_routes.households_shopping_lists_item_id_recipe(sample_list.id), + json=recipes_post_data, + headers=unique_user.token, + ) + assert response.status_code == 200 + + response = api_client.get( + api_routes.households_shopping_lists_item_id(sample_list.id), + headers=unique_user.token, + ) + as_json = utils.assert_deserialize(response, 200) + assert len(as_json["listItems"]) == len(all_ingredients) + + for item in as_json["listItems"]: + assert item["note"] in known_ingredients + + ingredient = known_ingredients[item["note"]] + assert item["quantity"] == (ingredient.quantity or 0) * 2 + + refs = as_json["recipeReferences"] + assert len(refs) == len(recipes) + refs_by_id = {ref["recipeId"]: ref for ref in refs} + for recipe in recipes: + assert str(recipe.id) in refs_by_id + assert refs_by_id[str(recipe.id)]["recipeQuantity"] == 2 + + def test_shopping_lists_add_one_with_zero_quantity( api_client: TestClient, unique_user: TestUser, @@ -209,7 +282,8 @@ def test_shopping_lists_add_one_with_zero_quantity( # add the recipe to the list and make sure there are three list items response = api_client.post( - api_routes.households_shopping_lists_item_id_recipe_recipe_id(shopping_list.id, recipe.id), + api_routes.households_shopping_lists_item_id_recipe(shopping_list.id), + json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump()]), headers=unique_user.token, ) @@ -242,16 +316,19 @@ def test_shopping_lists_add_custom_recipe_items( recipe = recipe_ingredient_only response = api_client.post( - api_routes.households_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id), + api_routes.households_shopping_lists_item_id_recipe(sample_list.id), + json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump()]), headers=unique_user.token, ) assert response.status_code == 200 custom_items = random.sample(recipe_ingredient_only.recipe_ingredient, k=3) response = api_client.post( - api_routes.households_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id), + api_routes.households_shopping_lists_item_id_recipe(sample_list.id), + json=utils.jsonify( + [ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id, recipe_ingredients=custom_items).model_dump()] + ), headers=unique_user.token, - json={"recipeIngredients": utils.jsonify(custom_items)}, ) assert response.status_code == 200 @@ -291,7 +368,8 @@ def test_shopping_list_ref_removes_itself( # add a recipe to a list, then check off all recipe items and make sure the recipe ref is deleted recipe = recipe_ingredient_only response = api_client.post( - api_routes.households_shopping_lists_item_id_recipe_recipe_id(shopping_list.id, recipe.id), + api_routes.households_shopping_lists_item_id_recipe(shopping_list.id), + json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump()]), headers=unique_user.token, ) utils.assert_deserialize(response, 200) @@ -365,7 +443,8 @@ def test_shopping_lists_add_recipe_with_merge( # add the recipe to the list and make sure there are only three list items, and their quantities/refs are correct response = api_client.post( - api_routes.households_shopping_lists_item_id_recipe_recipe_id(shopping_list.id, recipe.id), + api_routes.households_shopping_lists_item_id_recipe(shopping_list.id), + json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump()]), headers=unique_user.token, ) @@ -403,6 +482,85 @@ def test_shopping_lists_add_recipe_with_merge( assert all([found_item_1, found_item_2, found_duplicate_item]) +def test_shopping_lists_add_recipes_with_merge( + api_client: TestClient, + unique_user: TestUser, + shopping_lists: list[ShoppingListOut], +): + shopping_list = random.choice(shopping_lists) + + # build two recipes that share an ingredient + recipes: list[Recipe] = [] + common_note = random_string() + for _ in range(2): + response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token) + recipe_slug = utils.assert_deserialize(response, 201) + + response = api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token) + recipe_data = utils.assert_deserialize(response, 200) + + ingredient_unique = {"quantity": 1, "note": random_string()} + ingredient_duplicate = {"quantity": 1, "note": common_note} + + recipe_data["recipeIngredient"] = [ingredient_unique, ingredient_duplicate] + response = api_client.put( + f"{api_routes.recipes}/{recipe_slug}", + json=recipe_data, + headers=unique_user.token, + ) + utils.assert_deserialize(response, 200) + + recipe = Recipe.model_validate_json( + api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token).content + ) + assert recipe.id + assert len(recipe.recipe_ingredient) == 2 + recipes.append(recipe) + + # add the recipes to the list and make sure there are only five list items, and their quantities/refs are correct + response = api_client.post( + api_routes.households_shopping_lists_item_id_recipe(shopping_list.id), + json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump() for recipe in recipes]), + headers=unique_user.token, + ) + + response = api_client.get( + api_routes.households_shopping_lists_item_id(shopping_list.id), + headers=unique_user.token, + ) + shopping_list_out = ShoppingListOut.model_validate(utils.assert_deserialize(response, 200)) + + assert len(shopping_list_out.list_items) == 3 + + found_recipe_1_item = False + found_recipe_2_item = False + found_duplicate_item = False + for list_item in shopping_list_out.list_items: + if list_item.note == common_note: + assert list_item.quantity == 2 + assert len(list_item.recipe_references) == 2 + assert {ref.recipe_id for ref in list_item.recipe_references} == {recipe.id for recipe in recipes} + found_duplicate_item = True + + else: + assert len(list_item.recipe_references) == 1 + assert list_item.quantity == 1 + if list_item.note == recipes[0].recipe_ingredient[0].note: + assert list_item.recipe_references[0].recipe_id == recipes[0].id + found_recipe_1_item = True + elif list_item.note == recipes[1].recipe_ingredient[0].note: + assert list_item.recipe_references[0].recipe_id == recipes[1].id + found_recipe_2_item = True + else: + raise Exception("Unexpected item") + + for ref in list_item.recipe_references: + assert ref.recipe_scale == 1 + assert ref.recipe_quantity == 1 + + assert all([found_recipe_1_item, found_recipe_2_item, found_duplicate_item]) + + def test_shopping_list_add_recipe_scale( api_client: TestClient, unique_user: TestUser, @@ -413,7 +571,8 @@ def test_shopping_list_add_recipe_scale( recipe = recipe_ingredient_only response = api_client.post( - api_routes.households_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id), + api_routes.households_shopping_lists_item_id_recipe(sample_list.id), + json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump()]), headers=unique_user.token, ) @@ -440,10 +599,12 @@ def test_shopping_list_add_recipe_scale( assert refs[0]["recipeScale"] == 1 recipe_scale = round(random.uniform(1, 10), 5) - payload = {"recipeIncrementQuantity": recipe_scale} + payload = utils.jsonify( + [ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id, recipe_increment_quantity=recipe_scale).model_dump()] + ) response = api_client.post( - api_routes.households_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id), + api_routes.households_shopping_lists_item_id_recipe(sample_list.id), headers=unique_user.token, json=payload, ) @@ -476,9 +637,11 @@ def test_shopping_lists_remove_recipe( recipe = recipe_ingredient_only # add two instances of the recipe - payload = {"recipeIncrementQuantity": 2} + payload = utils.jsonify( + [ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id, recipe_increment_quantity=2).model_dump()] + ) response = api_client.post( - api_routes.households_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id), + api_routes.households_shopping_lists_item_id_recipe(sample_list.id), json=payload, headers=unique_user.token, ) @@ -539,7 +702,8 @@ def test_shopping_lists_remove_recipe_multiple_quantity( for _ in range(3): response = api_client.post( - api_routes.households_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id), + api_routes.households_shopping_lists_item_id_recipe(sample_list.id), + json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump()]), headers=unique_user.token, ) assert response.status_code == 200 @@ -592,11 +756,17 @@ def test_shopping_list_remove_recipe_scale( recipe = recipe_ingredient_only recipe_initital_scale = 100 - payload: dict = {"recipeIncrementQuantity": recipe_initital_scale} + payload = utils.jsonify( + [ + ShoppingListAddRecipeParamsBulk( + recipe_id=recipe.id, recipe_increment_quantity=recipe_initital_scale + ).model_dump() + ] + ) # first add a bunch of quantity to the list response = api_client.post( - api_routes.households_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id), + api_routes.households_shopping_lists_item_id_recipe(sample_list.id), headers=unique_user.token, json=payload, ) @@ -657,11 +827,13 @@ def test_recipe_decrement_max( recipe = recipe_ingredient_only recipe_scale = 10 - payload = {"recipeIncrementQuantity": recipe_scale} + payload = utils.jsonify( + [ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id, recipe_increment_quantity=recipe_scale).model_dump()] + ) # first add a bunch of quantity to the list response = api_client.post( - api_routes.households_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id), + api_routes.households_shopping_lists_item_id_recipe(sample_list.id), headers=unique_user.token, json=payload, ) @@ -757,13 +929,15 @@ def test_recipe_manipulation_with_zero_quantities( # add the recipe to the list twice and make sure the quantity is still zero response = api_client.post( - api_routes.households_shopping_lists_item_id_recipe_recipe_id(shopping_list.id, recipe.id), + api_routes.households_shopping_lists_item_id_recipe(shopping_list.id), + json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump()]), headers=unique_user.token, ) utils.assert_deserialize(response, 200) response = api_client.post( - api_routes.households_shopping_lists_item_id_recipe_recipe_id(shopping_list.id, recipe.id), + api_routes.households_shopping_lists_item_id_recipe(shopping_list.id), + json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe.id).model_dump()]), headers=unique_user.token, ) utils.assert_deserialize(response, 200) diff --git a/tests/utils/api_routes/__init__.py b/tests/utils/api_routes/__init__.py index c4db84795..02aee25c9 100644 --- a/tests/utils/api_routes/__init__.py +++ b/tests/utils/api_routes/__init__.py @@ -390,6 +390,11 @@ def households_shopping_lists_item_id_label_settings(item_id): return f"{prefix}/households/shopping/lists/{item_id}/label-settings" +def households_shopping_lists_item_id_recipe(item_id): + """`/api/households/shopping/lists/{item_id}/recipe`""" + return f"{prefix}/households/shopping/lists/{item_id}/recipe" + + def households_shopping_lists_item_id_recipe_recipe_id(item_id, recipe_id): """`/api/households/shopping/lists/{item_id}/recipe/{recipe_id}`""" return f"{prefix}/households/shopping/lists/{item_id}/recipe/{recipe_id}"