Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Bulk Add Recipes to Shopping List #5054

40 changes: 17 additions & 23 deletions frontend/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -342,12 +342,12 @@ export default defineComponent({
}

async function addRecipesToList() {
const promises: Promise<any>[] = [];
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) => {
Expand All @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions frontend/lib/api/types/household.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down
1 change: 1 addition & 0 deletions frontend/lib/api/types/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ export interface UserBase {
id: string;
username?: string | null;
admin: boolean;
fullName?: string | null;
}
export interface RecipeCategoryResponse {
name: string;
Expand Down
8 changes: 4 additions & 4 deletions frontend/lib/api/user/group-shopping-lists.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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`,

Expand All @@ -29,8 +29,8 @@ export class ShoppingListsApi extends BaseCRUDAPI<ShoppingListCreate, ShoppingLi
baseRoute = routes.shoppingLists;
itemRoute = routes.shoppingListsId;

async addRecipe(itemId: string, recipeId: string, recipeIncrementQuantity = 1, recipeIngredients: RecipeIngredient[] | null = null) {
return await this.requests.post(routes.shoppingListIdAddRecipe(itemId, recipeId), { recipeIncrementQuantity, recipeIngredients });
async addRecipes(itemId: string, data: ShoppingListAddRecipeParamsBulk[]) {
return await this.requests.post(routes.shoppingListIdAddRecipe(itemId), data);
}

async removeRecipe(itemId: string, recipeId: string, recipeDecrementQuantity = 1) {
Expand Down
2 changes: 1 addition & 1 deletion frontend/pages/shopping-lists/_id.vue
Original file line number Diff line number Diff line change
Expand Up @@ -834,7 +834,7 @@ export default defineComponent({

loadingCounter.value += 1;
recipeReferenceLoading.value = true;
const { data } = await userApi.shopping.lists.addRecipe(shoppingList.value.id, recipeId);
const { data } = await userApi.shopping.lists.addRecipes(shoppingList.value.id, [{ recipeId }]);
recipeReferenceLoading.value = false;
loadingCounter.value -= 1;

Expand Down
22 changes: 15 additions & 7 deletions mealie/routes/households/controller_shopping_lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from mealie.routes._base.mixins import HttpRepo
from mealie.schema.household.group_shopping_list import (
ShoppingListAddRecipeParams,
ShoppingListAddRecipeParamsBulk,
ShoppingListCreate,
ShoppingListItemCreate,
ShoppingListItemOut,
Expand Down Expand Up @@ -252,17 +253,24 @@ def update_label_settings(self, item_id: UUID4, data: list[ShoppingListMultiPurp

return updated_list

@router.post("/{item_id}/recipe/{recipe_id}", response_model=ShoppingListOut)
def add_recipe_ingredients_to_list(
self, item_id: UUID4, recipe_id: UUID4, data: ShoppingListAddRecipeParams | None = None
):
shopping_list, items = self.service.add_recipe_ingredients_to_list(
item_id, recipe_id, data.recipe_increment_quantity if data else 1, data.recipe_ingredients if data else None
)
@router.post("/{item_id}/recipe", response_model=ShoppingListOut)
def add_recipe_ingredients_to_list(self, item_id: UUID4, data: list[ShoppingListAddRecipeParamsBulk]):
shopping_list, items = self.service.add_recipe_ingredients_to_list(item_id, data)

publish_list_item_events(self.publish_event, items)
return shopping_list

@router.post("/{item_id}/recipe/{recipe_id}", response_model=ShoppingListOut, deprecated=True)
def add_single_recipe_ingredients_to_list(
self, item_id: UUID4, recipe_id: UUID4, data: ShoppingListAddRecipeParams | None = None
):
# Compatibility function for old API
# TODO: remove this function in the future

data = data or ShoppingListAddRecipeParams(recipe_increment_quantity=1)
bulk_data = [data.cast(ShoppingListAddRecipeParamsBulk, recipe_id=recipe_id)]
return self.add_recipe_ingredients_to_list(item_id, bulk_data)

@router.post("/{item_id}/recipe/{recipe_id}/delete", response_model=ShoppingListOut)
def remove_recipe_ingredients_from_list(
self, item_id: UUID4, recipe_id: UUID4, data: ShoppingListRemoveRecipeParams | None = None
Expand Down
2 changes: 2 additions & 0 deletions mealie/schema/household/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
)
from .group_shopping_list import (
ShoppingListAddRecipeParams,
ShoppingListAddRecipeParamsBulk,
ShoppingListCreate,
ShoppingListItemBase,
ShoppingListItemCreate,
Expand Down Expand Up @@ -113,6 +114,7 @@
"ReadInviteToken",
"SaveInviteToken",
"ShoppingListAddRecipeParams",
"ShoppingListAddRecipeParamsBulk",
"ShoppingListCreate",
"ShoppingListItemBase",
"ShoppingListItemCreate",
Expand Down
4 changes: 4 additions & 0 deletions mealie/schema/household/group_shopping_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,5 +292,9 @@ class ShoppingListAddRecipeParams(MealieModel):
"""optionally override which ingredients are added from the recipe"""


class ShoppingListAddRecipeParamsBulk(ShoppingListAddRecipeParams):
recipe_id: UUID4


class ShoppingListRemoveRecipeParams(MealieModel):
recipe_decrement_quantity: float = 1
52 changes: 29 additions & 23 deletions mealie/services/household_services/shopping_lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from mealie.core.exceptions import UnexpectedNone
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.household.group_shopping_list import (
ShoppingListAddRecipeParamsBulk,
ShoppingListCreate,
ShoppingListItemBase,
ShoppingListItemCreate,
Expand Down Expand Up @@ -135,11 +136,11 @@ def bulk_create_items(
consolidated_create_items: list[ShoppingListItemCreate] = []
for create_item in create_items:
merged = False
for filtered_item in consolidated_create_items:
for i, filtered_item in enumerate(consolidated_create_items):
if not self.can_merge(create_item, filtered_item):
continue

filtered_item = self.merge_items(create_item, filtered_item).cast(ShoppingListItemCreate)
consolidated_create_items[i] = self.merge_items(create_item, filtered_item).cast(ShoppingListItemCreate)
merged = True
break

Expand Down Expand Up @@ -207,11 +208,11 @@ def bulk_update_items(self, update_items: list[ShoppingListItemUpdateBulk]) -> S
seen_update_ids.add(update_item.id)

merged = False
for filtered_item in consolidated_update_items:
for i, filtered_item in enumerate(consolidated_update_items):
if not self.can_merge(update_item, filtered_item):
continue

filtered_item = self.merge_items(update_item, filtered_item).cast(
consolidated_update_items[i] = self.merge_items(update_item, filtered_item).cast(
ShoppingListItemUpdateBulk, id=filtered_item.id
)
delete_items.add(update_item.id)
Expand Down Expand Up @@ -373,38 +374,43 @@ def get_shopping_list_items_from_recipe(
def add_recipe_ingredients_to_list(
self,
list_id: UUID4,
recipe_id: UUID4,
recipe_increment: float = 1,
recipe_ingredients: list[RecipeIngredient] | None = None,
recipe_items: list[ShoppingListAddRecipeParamsBulk],
) -> 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
Expand Down
31 changes: 31 additions & 0 deletions tests/fixtures/fixture_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
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
Expand Down
Loading
Loading