diff --git a/frontend/components/Domain/ShoppingList/ShoppingListItem.vue b/frontend/components/Domain/ShoppingList/ShoppingListItem.vue index 5af1f40da2d3..a4198d632271 100644 --- a/frontend/components/Domain/ShoppingList/ShoppingListItem.vue +++ b/frontend/components/Domain/ShoppingList/ShoppingListItem.vue @@ -10,7 +10,7 @@ > @@ -55,10 +55,9 @@ import { defineComponent, computed, ref, useContext } from "@nuxtjs/composition-api"; import ShoppingListItemEditor from "./ShoppingListItemEditor.vue"; import MultiPurposeLabel from "./MultiPurposeLabel.vue"; -import { ShoppingListItemCreate } from "~/lib/api/types/group"; +import { ShoppingListItemOut } from "~/lib/api/types/group"; import { MultiPurposeLabelOut } from "~/lib/api/types/labels"; import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe"; -import { getDisplayText } from "~/composables/use-display-text"; import { MultiPurposeLabelSummary } from "~/lib/api/types/user"; interface actions { @@ -70,7 +69,7 @@ export default defineComponent({ components: { ShoppingListItemEditor, MultiPurposeLabel }, props: { value: { - type: Object as () => ShoppingListItemCreate, + type: Object as () => ShoppingListItemOut, required: true, }, labels: { @@ -147,10 +146,6 @@ export default defineComponent({ }); }); - const displayText = computed(() => - getDisplayText(listItem.value.note, listItem.value.quantity, listItem.value.food, listItem.value.unit) - ); - /** * Gets the label for the shopping list item. Either the label assign to the item * or the label of the food applied. @@ -170,7 +165,6 @@ export default defineComponent({ }); return { - displayText, updatedLabels, save, contextHandler, diff --git a/frontend/composables/use-display-text.ts b/frontend/composables/use-display-text.ts deleted file mode 100644 index bb287383919c..000000000000 --- a/frontend/composables/use-display-text.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * use-display-text module contains helpful utility functions to compute the display text when provided - * with the food, units, quantity, and notes. - */ - -import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe"; - -export function getDisplayText( - notes = "", - quantity: number | null = null, - food: IngredientFood | null = null, - unit: IngredientUnit | null = null -): string { - // Fallback to note only if no food or unit is provided - if (food === null && unit === null) { - return `${quantity || ""} ${notes}`.trim(); - } - - // Otherwise build the display text - let displayText = ""; - - if (quantity) { - displayText += quantity; - } - - if (unit) { - displayText += ` ${unit.name}`; - } - - if (food) { - displayText += ` ${food.name}`; - } - - if (notes) { - displayText += ` ${notes}`; - } - - return displayText.trim(); -} diff --git a/frontend/lib/api/types/group.ts b/frontend/lib/api/types/group.ts index af60a3b21dee..5e7cdeaf8a06 100644 --- a/frontend/lib/api/types/group.ts +++ b/frontend/lib/api/types/group.ts @@ -245,6 +245,9 @@ export interface SetPermissions { canInvite?: boolean; canOrganize?: boolean; } +export interface ShoppingListAddRecipeParams { + recipeIncrementQuantity?: number; +} export interface ShoppingListCreate { name?: string; extras?: { @@ -253,6 +256,20 @@ export interface ShoppingListCreate { createdAt?: string; updateAt?: string; } +export interface ShoppingListItemBase { + shoppingListId: string; + checked?: boolean; + position?: number; + isFood?: boolean; + note?: string; + quantity?: number; + foodId?: string; + labelId?: string; + unitId?: string; + extras?: { + [k: string]: unknown; + }; +} export interface ShoppingListItemCreate { shoppingListId: string; checked?: boolean; @@ -260,28 +277,38 @@ export interface ShoppingListItemCreate { isFood?: boolean; note?: string; quantity?: number; - unitId?: string; - unit?: IngredientUnit; foodId?: string; - food?: IngredientFood; labelId?: string; - recipeReferences?: ShoppingListItemRecipeRef[]; + unitId?: string; extras?: { [k: string]: unknown; }; - createdAt?: string; - updateAt?: string; + recipeReferences?: ShoppingListItemRecipeRefCreate[]; } -export interface IngredientUnit { - name: string; - description?: string; +export interface ShoppingListItemRecipeRefCreate { + recipeId: string; + recipeQuantity?: number; + recipeScale?: number; +} +export interface ShoppingListItemOut { + shoppingListId: string; + checked?: boolean; + position?: number; + isFood?: boolean; + note?: string; + quantity?: number; + foodId?: string; + labelId?: string; + unitId?: string; extras?: { [k: string]: unknown; }; - fraction?: boolean; - abbreviation?: string; - useAbbreviation?: boolean; id: string; + display?: string; + food?: IngredientFood; + label?: MultiPurposeLabelSummary; + unit?: IngredientUnit; + recipeReferences?: ShoppingListItemRecipeRefOut[]; createdAt?: string; updateAt?: string; } @@ -303,34 +330,30 @@ export interface MultiPurposeLabelSummary { groupId: string; id: string; } -export interface ShoppingListItemRecipeRef { - recipeId: string; - recipeQuantity?: number; -} -export interface ShoppingListItemOut { - shoppingListId: string; - checked?: boolean; - position?: number; - isFood?: boolean; - note?: string; - quantity?: number; - unitId?: string; - unit?: IngredientUnit; - foodId?: string; - food?: IngredientFood; - labelId?: string; - recipeReferences?: (ShoppingListItemRecipeRef | ShoppingListItemRecipeRefOut)[]; +export interface IngredientUnit { + name: string; + description?: string; extras?: { [k: string]: unknown; }; + fraction?: boolean; + abbreviation?: string; + useAbbreviation?: boolean; + id: string; createdAt?: string; updateAt?: string; - id: string; - label?: MultiPurposeLabelSummary; } export interface ShoppingListItemRecipeRefOut { recipeId: string; recipeQuantity?: number; + recipeScale?: number; + id: string; + shoppingListItemId: string; +} +export interface ShoppingListItemRecipeRefUpdate { + recipeId: string; + recipeQuantity?: number; + recipeScale?: number; id: string; shoppingListItemId: string; } @@ -341,19 +364,41 @@ export interface ShoppingListItemUpdate { isFood?: boolean; note?: string; quantity?: number; - unitId?: string; - unit?: IngredientUnit; foodId?: string; - food?: IngredientFood; labelId?: string; - recipeReferences?: ShoppingListItemRecipeRef[]; + unitId?: string; extras?: { [k: string]: unknown; }; - createdAt?: string; - updateAt?: string; + recipeReferences?: (ShoppingListItemRecipeRefCreate | ShoppingListItemRecipeRefUpdate)[]; +} +/** + * Only used for bulk update operations where the shopping list item id isn't already supplied + */ +export interface ShoppingListItemUpdateBulk { + shoppingListId: string; + checked?: boolean; + position?: number; + isFood?: boolean; + note?: string; + quantity?: number; + foodId?: string; + labelId?: string; + unitId?: string; + extras?: { + [k: string]: unknown; + }; + recipeReferences?: (ShoppingListItemRecipeRefCreate | ShoppingListItemRecipeRefUpdate)[]; id: string; } +/** + * Container for bulk shopping list item changes + */ +export interface ShoppingListItemsCollectionOut { + createdItems?: ShoppingListItemOut[]; + updatedItems?: ShoppingListItemOut[]; + deletedItems?: ShoppingListItemOut[]; +} export interface ShoppingListOut { name?: string; extras?: { @@ -442,6 +487,9 @@ export interface CreateIngredientFood { }; labelId?: string; } +export interface ShoppingListRemoveRecipeParams { + recipeDecrementQuantity?: number; +} export interface ShoppingListSave { name?: string; extras?: { diff --git a/frontend/lib/api/types/user.ts b/frontend/lib/api/types/user.ts index 659ae6869bd9..0fdbdd90f9b0 100644 --- a/frontend/lib/api/types/user.ts +++ b/frontend/lib/api/types/user.ts @@ -69,6 +69,7 @@ export interface LongLiveTokenOut { token: string; name: string; id: number; + createdAt?: string; } export interface ReadGroupPreferences { privateGroup?: boolean; diff --git a/frontend/lib/api/user/group-shopping-lists.ts b/frontend/lib/api/user/group-shopping-lists.ts index 4b9067533ee8..0c849eb09e75 100644 --- a/frontend/lib/api/user/group-shopping-lists.ts +++ b/frontend/lib/api/user/group-shopping-lists.ts @@ -4,7 +4,7 @@ import { ShoppingListCreate, ShoppingListItemCreate, ShoppingListItemOut, - ShoppingListItemUpdate, + ShoppingListItemUpdateBulk, ShoppingListOut, ShoppingListUpdate, } from "~/lib/api/types/group"; @@ -37,7 +37,7 @@ export class ShoppingListsApi extends BaseCRUDAPI { baseRoute = routes.shoppingListItems; itemRoute = routes.shoppingListItemsId; diff --git a/frontend/pages/shopping-lists/_id.vue b/frontend/pages/shopping-lists/_id.vue index f6aa940a7fec..f7ebe6130293 100644 --- a/frontend/pages/shopping-lists/_id.vue +++ b/frontend/pages/shopping-lists/_id.vue @@ -10,7 +10,7 @@
- + getDisplayText(itm.note, itm.quantity, itm.food, itm.unit)); + const text: string[] = items.map((itm) => itm.display || ""); switch (copyType) { case "markdown": @@ -514,7 +513,7 @@ export default defineComponent({ if (item.checked && shoppingList.value.listItems) { const lst = shoppingList.value.listItems.filter((itm) => itm.id !== item.id); lst.push(item); - updateIndex(lst); + updateListItems(); } const { data } = await userApi.shopping.items.updateOne(item.id, item); @@ -553,9 +552,9 @@ export default defineComponent({ isFood: false, quantity: 1, note: "", - unit: undefined, - food: undefined, labelId: undefined, + unitId: undefined, + foodId: undefined, }; } @@ -578,9 +577,10 @@ export default defineComponent({ } } - function updateIndex(data: ShoppingListItemOut[]) { + function updateIndexUnchecked(uncheckedItems: ShoppingListItemOut[]) { if (shoppingList.value?.listItems) { - shoppingList.value.listItems = data; + // move the new unchecked items in front of the checked items + shoppingList.value.listItems = uncheckedItems.concat(listItems.value.checked); } updateListItems(); @@ -646,7 +646,7 @@ export default defineComponent({ sortByLabels, toggleShowChecked, uncheckAll, - updateIndex, + updateIndexUnchecked, allUnits, allFoods, }; diff --git a/mealie/repos/repository_generic.py b/mealie/repos/repository_generic.py index 2ca2ce64addb..1e72dab2d607 100644 --- a/mealie/repos/repository_generic.py +++ b/mealie/repos/repository_generic.py @@ -1,7 +1,7 @@ from __future__ import annotations from math import ceil -from typing import Any, Generic, TypeVar +from typing import Any, Generic, Iterable, TypeVar from fastapi import HTTPException from pydantic import UUID4, BaseModel @@ -11,7 +11,11 @@ from sqlalchemy.orm.session import Session from sqlalchemy.sql import sqltypes from mealie.core.root_logger import get_logger -from mealie.schema.response.pagination import OrderDirection, PaginationBase, PaginationQuery +from mealie.schema.response.pagination import ( + OrderDirection, + PaginationBase, + PaginationQuery, +) from mealie.schema.response.query_filter import QueryFilter Schema = TypeVar("Schema", bound=BaseModel) @@ -158,7 +162,7 @@ class RepositoryGeneric(Generic[Schema, Model]): return self.schema.from_orm(new_document) - def create_many(self, data: list[Schema | dict]) -> list[Schema]: + def create_many(self, data: Iterable[Schema | dict]) -> list[Schema]: new_documents = [] for document in data: document = document if isinstance(document, dict) else document.dict() @@ -167,7 +171,9 @@ class RepositoryGeneric(Generic[Schema, Model]): self.session.add_all(new_documents) self.session.commit() - self.session.refresh(new_documents) + + for created_document in new_documents: + self.session.refresh(created_document) return [self.schema.from_orm(x) for x in new_documents] @@ -189,6 +195,23 @@ class RepositoryGeneric(Generic[Schema, Model]): self.session.commit() return self.schema.from_orm(entry) + def update_many(self, data: Iterable[Schema | dict]) -> list[Schema]: + document_data_by_id: dict[str, dict] = {} + for document in data: + document_data = document if isinstance(document, dict) else document.dict() + document_data_by_id[document_data["id"]] = document_data + + documents_to_update = self._query().filter(self.model.id.in_(list(document_data_by_id.keys()))) # type: ignore + + updated_documents = [] + for document_to_update in documents_to_update: + data = document_data_by_id[document_to_update.id] # type: ignore + document_to_update.update(session=self.session, **data) # type: ignore + updated_documents.append(document_to_update) + + self.session.commit() + return [self.schema.from_orm(x) for x in updated_documents] + def patch(self, match_value: str | int | UUID4, new_data: dict | BaseModel) -> Schema: new_data = new_data if isinstance(new_data, dict) else new_data.dict() @@ -214,6 +237,23 @@ class RepositoryGeneric(Generic[Schema, Model]): return results_as_model + def delete_many(self, values: Iterable) -> Schema: + results = self._query().filter(self.model.id.in_(values)) # type: ignore + results_as_model = [self.schema.from_orm(result) for result in results] + + try: + # we create a delete statement for each row + # we don't delete the whole query in one statement because postgres doesn't cascade correctly + for result in results: + self.session.delete(result) + + self.session.commit() + except Exception as e: + self.session.rollback() + raise e + + return results_as_model # type: ignore + def delete_all(self) -> None: self._query().delete() self.session.commit() diff --git a/mealie/routes/groups/controller_shopping_lists.py b/mealie/routes/groups/controller_shopping_lists.py index 4655e3046b5d..28b0c6728539 100644 --- a/mealie/routes/groups/controller_shopping_lists.py +++ b/mealie/routes/groups/controller_shopping_lists.py @@ -1,4 +1,5 @@ from functools import cached_property +from typing import Callable from fastapi import APIRouter, Depends, Query from pydantic import UUID4 @@ -11,7 +12,9 @@ from mealie.schema.group.group_shopping_list import ( ShoppingListCreate, ShoppingListItemCreate, ShoppingListItemOut, + ShoppingListItemsCollectionOut, ShoppingListItemUpdate, + ShoppingListItemUpdateBulk, ShoppingListOut, ShoppingListPagination, ShoppingListRemoveRecipeParams, @@ -26,7 +29,6 @@ from mealie.services.event_bus_service.event_types import ( EventOperation, EventShoppingListData, EventShoppingListItemBulkData, - EventShoppingListItemData, EventTypes, ) from mealie.services.group_services.shopping_lists import ShoppingListService @@ -34,6 +36,54 @@ from mealie.services.group_services.shopping_lists import ShoppingListService item_router = APIRouter(prefix="/groups/shopping/items", tags=["Group: Shopping List Items"]) +def publish_list_item_events(publisher: Callable, items_collection: ShoppingListItemsCollectionOut) -> None: + items_by_list_id: dict[UUID4, list[ShoppingListItemOut]] + if items_collection.created_items: + items_by_list_id = {} + for item in items_collection.created_items: + items_by_list_id.setdefault(item.shopping_list_id, []).append(item) + + for shopping_list_id, items in items_by_list_id.items(): + publisher( + EventTypes.shopping_list_updated, + document_data=EventShoppingListItemBulkData( + operation=EventOperation.create, + shopping_list_id=shopping_list_id, + shopping_list_item_ids=[item.id for item in items], + ), + ) + + if items_collection.updated_items: + items_by_list_id = {} + for item in items_collection.updated_items: + items_by_list_id.setdefault(item.shopping_list_id, []).append(item) + + for shopping_list_id, items in items_by_list_id.items(): + publisher( + EventTypes.shopping_list_updated, + document_data=EventShoppingListItemBulkData( + operation=EventOperation.update, + shopping_list_id=shopping_list_id, + shopping_list_item_ids=[item.id for item in items], + ), + ) + + if items_collection.deleted_items: + items_by_list_id = {} + for item in items_collection.deleted_items: + items_by_list_id.setdefault(item.shopping_list_id, []).append(item) + + for shopping_list_id, items in items_by_list_id.items(): + publisher( + EventTypes.shopping_list_updated, + document_data=EventShoppingListItemBulkData( + operation=EventOperation.delete, + shopping_list_id=shopping_list_id, + shopping_list_item_ids=[item.id for item in items], + ), + ) + + @controller(item_router) class ShoppingListItemController(BaseCrudController): @cached_property @@ -51,96 +101,43 @@ class ShoppingListItemController(BaseCrudController): self.logger, ) - @item_router.put("", response_model=list[ShoppingListItemOut]) - def update_many(self, data: list[ShoppingListItemUpdate]): - # TODO: Convert to update many with single call + @item_router.post("/create-bulk", response_model=ShoppingListItemsCollectionOut, status_code=201) + def create_many(self, data: list[ShoppingListItemCreate]): + items = self.service.bulk_create_items(data) + publish_list_item_events(self.publish_event, items) + return items - all_updates = [] - keep_ids = [] - - for item in self.service.consolidate_list_items(data): - updated_data = self.mixins.update_one(item, item.id) - all_updates.append(updated_data) - keep_ids.append(updated_data.id) - - for item in data: - if item.id not in keep_ids: - self.mixins.delete_one(item.id) - - return all_updates - - @item_router.delete("", response_model=SuccessResponse) - def delete_many(self, ids: list[UUID4] = Query(None)): - x = 0 - for item_id in ids: - self.mixins.delete_one(item_id) - x += 1 - - return SuccessResponse.respond(message=f"Successfully deleted {x} items") - - @item_router.post("", response_model=ShoppingListItemOut, status_code=201) + @item_router.post("", response_model=ShoppingListItemsCollectionOut, status_code=201) def create_one(self, data: ShoppingListItemCreate): - shopping_list_item = self.mixins.create_one(data) - - if shopping_list_item: - self.publish_event( - event_type=EventTypes.shopping_list_updated, - document_data=EventShoppingListItemData( - operation=EventOperation.create, - shopping_list_id=shopping_list_item.shopping_list_id, - shopping_list_item_id=shopping_list_item.id, - ), - message=self.t( - "notifications.generic-created", - name=f"An item on shopping list {shopping_list_item.shopping_list_id}", - ), - ) - - return shopping_list_item + return self.create_many([data]) @item_router.get("/{item_id}", response_model=ShoppingListItemOut) def get_one(self, item_id: UUID4): return self.mixins.get_one(item_id) - @item_router.put("/{item_id}", response_model=ShoppingListItemOut) + @item_router.put("", response_model=ShoppingListItemsCollectionOut) + def update_many(self, data: list[ShoppingListItemUpdateBulk]): + items = self.service.bulk_update_items(data) + publish_list_item_events(self.publish_event, items) + return items + + @item_router.put("/{item_id}", response_model=ShoppingListItemsCollectionOut) def update_one(self, item_id: UUID4, data: ShoppingListItemUpdate): - shopping_list_item = self.mixins.update_one(data, item_id) + return self.update_many([data.cast(ShoppingListItemUpdateBulk, id=item_id)]) - if shopping_list_item: - self.publish_event( - event_type=EventTypes.shopping_list_updated, - document_data=EventShoppingListItemData( - operation=EventOperation.update, - shopping_list_id=shopping_list_item.shopping_list_id, - shopping_list_item_id=shopping_list_item.id, - ), - message=self.t( - "notifications.generic-updated", - name=f"An item on shopping list {shopping_list_item.shopping_list_id}", - ), - ) + @item_router.delete("", response_model=SuccessResponse) + def delete_many(self, ids: list[UUID4] = Query(None)): + items = self.service.bulk_delete_items(ids) + publish_list_item_events(self.publish_event, items) - return shopping_list_item + message = ( + f"Successfully deleted {len(items.deleted_items)} {'item' if len(items.deleted_items) == 1 else 'items'}" + ) + return SuccessResponse.respond(message=message) - @item_router.delete("/{item_id}", response_model=ShoppingListItemOut) + @item_router.delete("/{item_id}", response_model=SuccessResponse) def delete_one(self, item_id: UUID4): - shopping_list_item = self.mixins.delete_one(item_id) # type: ignore - - if shopping_list_item: - self.publish_event( - event_type=EventTypes.shopping_list_updated, - document_data=EventShoppingListItemData( - operation=EventOperation.delete, - shopping_list_id=shopping_list_item.shopping_list_id, - shopping_list_item_id=shopping_list_item.id, - ), - message=self.t( - "notifications.generic-deleted", - name=f"An item on shopping list {shopping_list_item.shopping_list_id}", - ), - ) - - return shopping_list_item + return self.delete_many([item_id]) router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists"]) @@ -223,85 +220,20 @@ class ShoppingListController(BaseCrudController): def add_recipe_ingredients_to_list( self, item_id: UUID4, recipe_id: UUID4, data: ShoppingListAddRecipeParams | None = None ): - ( - shopping_list, - new_shopping_list_items, - updated_shopping_list_items, - deleted_shopping_list_items, - ) = self.service.add_recipe_ingredients_to_list( + shopping_list, items = self.service.add_recipe_ingredients_to_list( item_id, recipe_id, data.recipe_increment_quantity if data else 1 ) - if new_shopping_list_items: - self.publish_event( - event_type=EventTypes.shopping_list_updated, - document_data=EventShoppingListItemBulkData( - operation=EventOperation.create, - shopping_list_id=shopping_list.id, - shopping_list_item_ids=[shopping_list_item.id for shopping_list_item in new_shopping_list_items], - ), - ) - - if updated_shopping_list_items: - self.publish_event( - event_type=EventTypes.shopping_list_updated, - document_data=EventShoppingListItemBulkData( - operation=EventOperation.update, - shopping_list_id=shopping_list.id, - shopping_list_item_ids=[ - shopping_list_item.id for shopping_list_item in updated_shopping_list_items - ], - ), - ) - - if deleted_shopping_list_items: - self.publish_event( - event_type=EventTypes.shopping_list_updated, - document_data=EventShoppingListItemBulkData( - operation=EventOperation.delete, - shopping_list_id=shopping_list.id, - shopping_list_item_ids=[ - shopping_list_item.id for shopping_list_item in deleted_shopping_list_items - ], - ), - ) - + publish_list_item_events(self.publish_event, items) return shopping_list @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 ): - ( - shopping_list, - updated_shopping_list_items, - deleted_shopping_list_items, - ) = self.service.remove_recipe_ingredients_from_list( + shopping_list, items = self.service.remove_recipe_ingredients_from_list( item_id, recipe_id, data.recipe_decrement_quantity if data else 1 ) - if updated_shopping_list_items: - self.publish_event( - event_type=EventTypes.shopping_list_updated, - document_data=EventShoppingListItemBulkData( - operation=EventOperation.update, - shopping_list_id=shopping_list.id, - shopping_list_item_ids=[ - shopping_list_item.id for shopping_list_item in updated_shopping_list_items - ], - ), - ) - - if deleted_shopping_list_items: - self.publish_event( - event_type=EventTypes.shopping_list_updated, - document_data=EventShoppingListItemBulkData( - operation=EventOperation.delete, - shopping_list_id=shopping_list.id, - shopping_list_item_ids=[ - shopping_list_item.id for shopping_list_item in deleted_shopping_list_items - ], - ), - ) - + publish_list_item_events(self.publish_event, items) return shopping_list diff --git a/mealie/schema/_mealie/mealie_model.py b/mealie/schema/_mealie/mealie_model.py index defcb15a333f..9a948fce97aa 100644 --- a/mealie/schema/_mealie/mealie_model.py +++ b/mealie/schema/_mealie/mealie_model.py @@ -42,3 +42,13 @@ class MealieModel(BaseModel): for field in src.__fields__: if field in self.__fields__: setattr(self, field, getattr(src, field)) + + def merge(self, src: T, replace_null=False): + """ + Replace matching values from another instance to the current instance. + """ + + for field in src.__fields__: + val = getattr(src, field) + if field in self.__fields__ and (val is not None or replace_null): + setattr(self, field, val) diff --git a/mealie/schema/group/__init__.py b/mealie/schema/group/__init__.py index efa946758779..dfec67a932a7 100644 --- a/mealie/schema/group/__init__.py +++ b/mealie/schema/group/__init__.py @@ -14,36 +14,50 @@ from .group_events import ( from .group_exports import GroupDataExport from .group_migration import DataMigrationCreate, SupportedMigrations from .group_permissions import SetPermissions -from .group_preferences import CreateGroupPreferences, ReadGroupPreferences, UpdateGroupPreferences +from .group_preferences import ( + CreateGroupPreferences, + ReadGroupPreferences, + UpdateGroupPreferences, +) from .group_seeder import SeederConfig from .group_shopping_list import ( + ShoppingListAddRecipeParams, ShoppingListCreate, + ShoppingListItemBase, ShoppingListItemCreate, ShoppingListItemOut, - ShoppingListItemRecipeRef, + ShoppingListItemRecipeRefCreate, ShoppingListItemRecipeRefOut, + ShoppingListItemRecipeRefUpdate, + ShoppingListItemsCollectionOut, ShoppingListItemUpdate, + ShoppingListItemUpdateBulk, ShoppingListOut, ShoppingListPagination, ShoppingListRecipeRefOut, + ShoppingListRemoveRecipeParams, ShoppingListSave, ShoppingListSummary, ShoppingListUpdate, ) from .group_statistics import GroupStatistics, GroupStorage -from .invite_token import CreateInviteToken, EmailInitationResponse, EmailInvitation, ReadInviteToken, SaveInviteToken -from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType +from .invite_token import ( + CreateInviteToken, + EmailInitationResponse, + EmailInvitation, + ReadInviteToken, + SaveInviteToken, +) +from .webhook import ( + CreateWebhook, + ReadWebhook, + SaveWebhook, + WebhookPagination, + WebhookType, +) __all__ = [ - "CreateGroupPreferences", - "ReadGroupPreferences", - "UpdateGroupPreferences", - "GroupDataExport", - "CreateWebhook", - "ReadWebhook", - "SaveWebhook", - "WebhookPagination", - "WebhookType", + "GroupAdminUpdate", "GroupEventNotifierCreate", "GroupEventNotifierOptions", "GroupEventNotifierOptionsOut", @@ -53,23 +67,32 @@ __all__ = [ "GroupEventNotifierSave", "GroupEventNotifierUpdate", "GroupEventPagination", + "GroupDataExport", "DataMigrationCreate", "SupportedMigrations", + "SetPermissions", + "CreateGroupPreferences", + "ReadGroupPreferences", + "UpdateGroupPreferences", "SeederConfig", + "ShoppingListAddRecipeParams", "ShoppingListCreate", + "ShoppingListItemBase", "ShoppingListItemCreate", "ShoppingListItemOut", - "ShoppingListItemRecipeRef", + "ShoppingListItemRecipeRefCreate", "ShoppingListItemRecipeRefOut", + "ShoppingListItemRecipeRefUpdate", + "ShoppingListItemsCollectionOut", "ShoppingListItemUpdate", + "ShoppingListItemUpdateBulk", "ShoppingListOut", "ShoppingListPagination", "ShoppingListRecipeRefOut", + "ShoppingListRemoveRecipeParams", "ShoppingListSave", "ShoppingListSummary", "ShoppingListUpdate", - "GroupAdminUpdate", - "SetPermissions", "GroupStatistics", "GroupStorage", "CreateInviteToken", @@ -77,4 +100,9 @@ __all__ = [ "EmailInvitation", "ReadInviteToken", "SaveInviteToken", + "CreateWebhook", + "ReadWebhook", + "SaveWebhook", + "WebhookPagination", + "WebhookType", ] diff --git a/mealie/schema/group/group_shopping_list.py b/mealie/schema/group/group_shopping_list.py index 02b82e8a49c2..e9ea6baf1f4e 100644 --- a/mealie/schema/group/group_shopping_list.py +++ b/mealie/schema/group/group_shopping_list.py @@ -1,35 +1,50 @@ from __future__ import annotations from datetime import datetime +from fractions import Fraction -from pydantic import UUID4 +from pydantic import UUID4, validator from pydantic.utils import GetterDict from mealie.db.models.group.shopping_list import ShoppingList, ShoppingListItem from mealie.schema._mealie import MealieModel from mealie.schema._mealie.types import NoneFloat -from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit +from mealie.schema.recipe.recipe_ingredient import ( + INGREDIENT_QTY_PRECISION, + MAX_INGREDIENT_DENOMINATOR, + IngredientFood, + IngredientUnit, +) from mealie.schema.response.pagination import PaginationBase +SUPERSCRIPT = dict(zip("1234567890", "¹²³⁴⁵⁶⁷⁸⁹⁰", strict=False)) +SUBSCRIPT = dict(zip("1234567890", "₁₂₃₄₅₆₇₈₉₀", strict=False)) -class ShoppingListItemRecipeRef(MealieModel): + +class ShoppingListItemRecipeRefCreate(MealieModel): recipe_id: UUID4 - recipe_quantity: NoneFloat = 0 + recipe_quantity: float = 0 """the quantity of this item in a single recipe (scale == 1)""" recipe_scale: NoneFloat = 1 """the number of times this recipe has been added""" + @validator("recipe_quantity", pre=True) + def default_none_to_zero(cls, v): + return 0 if v is None else v -class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRef): + +class ShoppingListItemRecipeRefUpdate(ShoppingListItemRecipeRefCreate): id: UUID4 shopping_list_item_id: UUID4 + +class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRefUpdate): class Config: orm_mode = True -class ShoppingListItemCreate(MealieModel): +class ShoppingListItemBase(MealieModel): shopping_list_id: UUID4 checked: bool = False position: int = 0 @@ -38,26 +53,110 @@ class ShoppingListItemCreate(MealieModel): note: str | None = "" quantity: float = 1 - unit_id: UUID4 | None = None - unit: IngredientUnit | None - food_id: UUID4 | None = None - food: IngredientFood | None + food_id: UUID4 | None = None label_id: UUID4 | None = None - recipe_references: list[ShoppingListItemRecipeRef] = [] + unit_id: UUID4 | None = None + extras: dict | None = {} + +class ShoppingListItemCreate(ShoppingListItemBase): + recipe_references: list[ShoppingListItemRecipeRefCreate] = [] + + +class ShoppingListItemUpdate(ShoppingListItemBase): + recipe_references: list[ShoppingListItemRecipeRefCreate | ShoppingListItemRecipeRefUpdate] = [] + + +class ShoppingListItemUpdateBulk(ShoppingListItemUpdate): + """Only used for bulk update operations where the shopping list item id isn't already supplied""" + + id: UUID4 + + +class ShoppingListItemOut(ShoppingListItemBase): + id: UUID4 + display: str = "" + """ + How the ingredient should be displayed + + Automatically calculated after the object is created + """ + + food: IngredientFood | None + label: MultiPurposeLabelSummary | None + unit: IngredientUnit | None + + recipe_references: list[ShoppingListItemRecipeRefOut] = [] + created_at: datetime | None update_at: datetime | None + def __init__(self, **kwargs): + super().__init__(**kwargs) -class ShoppingListItemUpdate(ShoppingListItemCreate): - id: UUID4 + # if we're missing a label, but the food has a label, use that as the label + if (not self.label) and (self.food and self.food.label): + self.label = self.food.label + self.label_id = self.label.id + # format the display property + if not self.display: + self.display = self._format_display() -class ShoppingListItemOut(ShoppingListItemUpdate): - label: MultiPurposeLabelSummary | None - recipe_references: list[ShoppingListItemRecipeRef | ShoppingListItemRecipeRefOut] = [] + def _format_quantity_for_display(self) -> str: + """How the quantity should be displayed""" + + qty: float | Fraction + + # decimal + if not self.unit or not self.unit.fraction: + qty = round(self.quantity, INGREDIENT_QTY_PRECISION) + if qty.is_integer(): + return str(int(qty)) + + else: + return str(qty) + + # fraction + qty = Fraction(self.quantity).limit_denominator(MAX_INGREDIENT_DENOMINATOR) + if qty.denominator == 1: + return str(qty.numerator) + + if qty.numerator <= qty.denominator: + return f"{SUPERSCRIPT[str(qty.numerator)]}⁄{SUBSCRIPT[str(qty.denominator)]}" + + # convert an improper fraction into a mixed fraction (e.g. 11/4 --> 2 3/4) + whole_number = 0 + while qty.numerator > qty.denominator: + whole_number += 1 + qty -= 1 + + return f"{whole_number} {SUPERSCRIPT[str(qty.numerator)]}⁄{SUBSCRIPT[str(qty.denominator)]}" + + def _format_display(self) -> str: + components = [] + + # ingredients with no food come across with a qty of 1, which looks weird + # e.g. "1 2 tbsp of olive oil" + if self.quantity and (self.is_food or self.quantity != 1): + components.append(self._format_quantity_for_display()) + + if not self.is_food: + components.append(self.note or "") + + else: + if self.quantity and self.unit: + components.append(self.unit.abbreviation if self.unit.use_abbreviation else self.unit.name) + + if self.food: + components.append(self.food.name) + + if self.note: + components.append(self.note) + + return " ".join(components) class Config: orm_mode = True @@ -70,6 +169,14 @@ class ShoppingListItemOut(ShoppingListItemUpdate): } +class ShoppingListItemsCollectionOut(MealieModel): + """Container for bulk shopping list item changes""" + + created_items: list[ShoppingListItemOut] = [] + updated_items: list[ShoppingListItemOut] = [] + deleted_items: list[ShoppingListItemOut] = [] + + class ShoppingListCreate(MealieModel): name: str | None = None extras: dict | None = {} diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py index 1455b3c22bba..afd3ba2e1eab 100644 --- a/mealie/schema/recipe/recipe_ingredient.py +++ b/mealie/schema/recipe/recipe_ingredient.py @@ -12,6 +12,9 @@ from mealie.schema._mealie import MealieModel from mealie.schema._mealie.types import NoneFloat from mealie.schema.response.pagination import PaginationBase +INGREDIENT_QTY_PRECISION = 3 +MAX_INGREDIENT_DENOMINATOR = 32 + class UnitFoodBase(MealieModel): name: str @@ -97,7 +100,7 @@ class RecipeIngredient(MealieModel): empty string. """ if isinstance(value, float): - return round(value, 3) + return round(value, INGREDIENT_QTY_PRECISION) if value is None or value == "": return None return value @@ -115,7 +118,7 @@ class IngredientConfidence(MealieModel): @classmethod def validate_quantity(cls, value, values) -> NoneFloat: if isinstance(value, float): - return round(value, 3) + return round(value, INGREDIENT_QTY_PRECISION) if value is None or value == "": return None return value diff --git a/mealie/services/group_services/shopping_lists.py b/mealie/services/group_services/shopping_lists.py index 343792f7e8d7..ddd1c41bb964 100644 --- a/mealie/services/group_services/shopping_lists.py +++ b/mealie/services/group_services/shopping_lists.py @@ -6,12 +6,16 @@ from mealie.core.exceptions import UnexpectedNone from mealie.repos.repository_factory import AllRepositories from mealie.schema.group import ShoppingListItemCreate, ShoppingListOut from mealie.schema.group.group_shopping_list import ( + ShoppingListItemBase, ShoppingListItemOut, - ShoppingListItemRecipeRef, + ShoppingListItemRecipeRefCreate, ShoppingListItemRecipeRefOut, + ShoppingListItemsCollectionOut, ShoppingListItemUpdate, + ShoppingListItemUpdateBulk, ) -from mealie.schema.recipe import Recipe +from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit +from mealie.schema.response.pagination import PaginationQuery class ShoppingListService: @@ -23,240 +27,359 @@ class ShoppingListService: self.list_refs = repos.group_shopping_list_recipe_refs @staticmethod - def can_merge(item1: ShoppingListItemOut, item2: ShoppingListItemOut) -> bool: - """ - can_merge checks if the two items can be merged together. - """ + def can_merge(item1: ShoppingListItemBase, item2: ShoppingListItemBase) -> bool: + """Check to see if this item can be merged with another item""" - # Check if items are both checked or both unchecked - if item1.checked != item2.checked: + if any( + [ + item1.checked, + item2.checked, + item1.food_id != item2.food_id, + item1.unit_id != item2.unit_id, + ] + ): return False - # Check if foods are equal - foods_is_none = item1.food_id is None and item2.food_id is None - foods_not_none = not foods_is_none - foods_equal = item1.food_id == item2.food_id + # if foods match, we can merge, otherwise compare the notes + return bool(item1.food_id) or item1.note == item2.note - # Check if units are equal - units_is_none = item1.unit_id is None and item2.unit_id is None - units_not_none = not units_is_none - units_equal = item1.unit_id == item2.unit_id - - # Check if notes are equal - if foods_is_none and units_is_none: - return item1.note == item2.note - - if foods_not_none and units_not_none: - return foods_equal and units_equal - - if foods_not_none: - return foods_equal - - return False - - def consolidate_list_items(self, item_list: list[ShoppingListItemOut]) -> list[ShoppingListItemOut]: + @staticmethod + def merge_items( + from_item: ShoppingListItemCreate | ShoppingListItemUpdateBulk, + to_item: ShoppingListItemCreate | ShoppingListItemUpdateBulk | ShoppingListItemOut, + ) -> ShoppingListItemUpdate: """ - iterates through the shopping list provided and returns - a consolidated list where all items that are matched against multiple values are - de-duplicated and only the first item is kept where the quantity is updated accordingly. + Takes an item and merges it into an already-existing item, then returns a copy + + Attributes of the `to_item` take priority over the `from_item`, except extras with overlapping keys """ - consolidated_list: list[ShoppingListItemOut] = [] - checked_items: list[int] = [] + to_item.quantity += from_item.quantity + if to_item.note != from_item.note: + to_item.note = " | ".join([note for note in [to_item.note, from_item.note] if note]) - for base_index, base_item in enumerate(item_list): - if base_index in checked_items: + if to_item.extras and from_item.extras: + to_item.extras.update(from_item.extras) + + updated_refs = {ref.recipe_id: ref for ref in from_item.recipe_references} + for to_ref in to_item.recipe_references: + if to_ref.recipe_id not in updated_refs: + updated_refs[to_ref.recipe_id] = to_ref continue - checked_items.append(base_index) - for inner_index, inner_item in enumerate(item_list): - if inner_index in checked_items: + # merge recipe scales + base_ref = updated_refs[to_ref.recipe_id] + + # if the scale is missing we assume it's 1 for backwards compatibility + # if the scale is 0 we leave it alone + if base_ref.recipe_scale is None: + base_ref.recipe_scale = 1 + + if to_ref.recipe_scale is None: + to_ref.recipe_scale = 1 + + base_ref.recipe_scale += to_ref.recipe_scale + + return to_item.cast(ShoppingListItemUpdate, recipe_references=list(updated_refs.values())) + + def remove_unused_recipe_references(self, shopping_list_id: UUID4) -> None: + shopping_list = cast(ShoppingListOut, self.shopping_lists.get_one(shopping_list_id)) + + recipe_ids_to_keep: set[UUID4] = set() + for item in shopping_list.list_items: + recipe_ids_to_keep.update([ref.recipe_id for ref in item.recipe_references]) + + list_refs_to_delete: set[UUID4] = set() + for list_ref in shopping_list.recipe_references: + if list_ref.recipe_id not in recipe_ids_to_keep: + list_refs_to_delete.add(list_ref.id) + + if list_refs_to_delete: + self.list_refs.delete_many(list_refs_to_delete) + + def bulk_create_items(self, create_items: list[ShoppingListItemCreate]) -> ShoppingListItemsCollectionOut: + # consolidate items to be created + consolidated_create_items: list[ShoppingListItemCreate] = [] + for create_item in create_items: + merged = False + for filtered_item in consolidated_create_items: + if not self.can_merge(create_item, filtered_item): continue - if ShoppingListService.can_merge(base_item, inner_item): - # Set Quantity - base_item.quantity += inner_item.quantity + filtered_item = self.merge_items(create_item, filtered_item).cast(ShoppingListItemCreate) + merged = True + break - # Set References - refs = {ref.recipe_id: ref for ref in base_item.recipe_references} - for inner_ref in inner_item.recipe_references: - if inner_ref.recipe_id not in refs: - refs[inner_ref.recipe_id] = inner_ref + if not merged: + consolidated_create_items.append(create_item) - else: - # merge recipe scales - base_ref = refs[inner_ref.recipe_id] + create_items = consolidated_create_items + filtered_create_items: list[ShoppingListItemCreate] = [] - # if the scale is missing we assume it's 1 for backwards compatibility - # if the scale is 0 we leave it alone - if base_ref.recipe_scale is None: - base_ref.recipe_scale = 1 + # check to see if we can merge into any existing items + update_items: list[ShoppingListItemUpdateBulk] = [] + existing_items_map: dict[UUID4, list[ShoppingListItemOut]] = {} + for create_item in create_items: + if create_item.shopping_list_id not in existing_items_map: + query = PaginationQuery( + per_page=-1, query_filter=f"shopping_list_id={create_item.shopping_list_id} AND checked=false" + ) + items_data = self.list_items.page_all(query) + existing_items_map[create_item.shopping_list_id] = items_data.items - if inner_ref.recipe_scale is None: - inner_ref.recipe_scale = 1 + merged = False + for existing_item in existing_items_map[create_item.shopping_list_id]: + if not self.can_merge(existing_item, create_item): + continue - base_ref.recipe_scale += inner_ref.recipe_scale + updated_existing_item = self.merge_items(create_item, existing_item).cast( + ShoppingListItemUpdateBulk, id=existing_item.id + ) + update_items.append(updated_existing_item.cast(ShoppingListItemUpdateBulk, id=existing_item.id)) + merged = True + break - base_item.recipe_references = list(refs.values()) - checked_items.append(inner_index) + if merged or create_item.quantity < 0: + continue - consolidated_list.append(base_item) + # create the item + if create_item.checked: + # checked items should not have recipe references + create_item.recipe_references = [] - return consolidated_list + filtered_create_items.append(create_item) - def consolidate_and_save( - self, data: list[ShoppingListItemUpdate] - ) -> tuple[list[ShoppingListItemOut], list[ShoppingListItemOut]]: - """ - returns: - - updated_shopping_list_items - - deleted_shopping_list_items - """ - # TODO: Convert to update many with single call + created_items = cast( + list[ShoppingListItemOut], + self.list_items.create_many(filtered_create_items) if filtered_create_items else [], # type: ignore + ) - all_updates = [] - all_deletes = [] - keep_ids = [] + updated_items = cast( + list[ShoppingListItemOut], + self.list_items.update_many(update_items) if update_items else [], # type: ignore + ) - for item in self.consolidate_list_items(data): # type: ignore - updated_data = self.list_items.update(item.id, item) - all_updates.append(updated_data) - keep_ids.append(updated_data.id) + for list_id in set(item.shopping_list_id for item in created_items + updated_items): + self.remove_unused_recipe_references(list_id) - for item in data: # type: ignore - if item.id not in keep_ids: - self.list_items.delete(item.id) - all_deletes.append(item) + return ShoppingListItemsCollectionOut( + created_items=created_items, updated_items=updated_items, deleted_items=[] + ) - return all_updates, all_deletes + def bulk_update_items(self, update_items: list[ShoppingListItemUpdateBulk]) -> ShoppingListItemsCollectionOut: + # consolidate items to be created + consolidated_update_items: list[ShoppingListItemUpdateBulk] = [] + delete_items: set[UUID4] = set() + seen_update_ids: set[UUID4] = set() + for update_item in update_items: + # if the same item appears multiple times in one request, ignore all but the first instance + if update_item.id in seen_update_ids: + continue - # ======================================================================= - # Methods + seen_update_ids.add(update_item.id) - def add_recipe_ingredients_to_list( - self, list_id: UUID4, recipe_id: UUID4, recipe_increment: float = 1 - ) -> tuple[ShoppingListOut, list[ShoppingListItemOut], list[ShoppingListItemOut], list[ShoppingListItemOut]]: - """ - returns: - - updated_shopping_list - - new_shopping_list_items - - updated_shopping_list_items - - deleted_shopping_list_items - """ - recipe: Recipe | None = self.repos.recipes.get_one(recipe_id, "id") + merged = False + for filtered_item in consolidated_update_items: + if not self.can_merge(update_item, filtered_item): + continue + + filtered_item = self.merge_items(update_item, filtered_item).cast( + ShoppingListItemUpdateBulk, id=filtered_item.id + ) + delete_items.add(update_item.id) + merged = True + break + + if not merged: + consolidated_update_items.append(update_item) + + update_items = consolidated_update_items + + # check to see if we can merge into any existing items + filtered_update_items: list[ShoppingListItemUpdateBulk] = [] + existing_items_map: dict[UUID4, list[ShoppingListItemOut]] = {} + for update_item in update_items: + if update_item.shopping_list_id not in existing_items_map: + query = PaginationQuery( + per_page=-1, query_filter=f"shopping_list_id={update_item.shopping_list_id} AND checked=false" + ) + items_data = self.list_items.page_all(query) + existing_items_map[update_item.shopping_list_id] = items_data.items + + merged = False + for existing_item in existing_items_map[update_item.shopping_list_id]: + if existing_item.id in delete_items or existing_item.id == update_item.id: + continue + + if not self.can_merge(update_item, existing_item): + continue + + updated_existing_item = self.merge_items(update_item, existing_item).cast( + ShoppingListItemUpdateBulk, id=existing_item.id + ) + filtered_update_items.append(updated_existing_item) + delete_items.add(update_item.id) + merged = True + break + + if merged: + continue + + # update or delete the item + if update_item.quantity < 0: + delete_items.add(update_item.id) + continue + + if update_item.checked: + # checked items should not have recipe references + update_item.recipe_references = [] + + filtered_update_items.append(update_item) + + updated_items = cast( + list[ShoppingListItemOut], + self.list_items.update_many(filtered_update_items) if filtered_update_items else [], # type: ignore + ) + + deleted_items = cast( + list[ShoppingListItemOut], + self.list_items.delete_many(delete_items) if delete_items else [], # type: ignore + ) + + for list_id in set(item.shopping_list_id for item in updated_items + deleted_items): + self.remove_unused_recipe_references(list_id) + + return ShoppingListItemsCollectionOut( + created_items=[], updated_items=updated_items, deleted_items=deleted_items + ) + + def bulk_delete_items(self, delete_items: list[UUID4]) -> ShoppingListItemsCollectionOut: + deleted_items = cast( + list[ShoppingListItemOut], + self.list_items.delete_many(set(delete_items)) if delete_items else [], # type: ignore + ) + + for list_id in set(item.shopping_list_id for item in deleted_items): + self.remove_unused_recipe_references(list_id) + + return ShoppingListItemsCollectionOut(created_items=[], updated_items=[], deleted_items=deleted_items) + + def get_shopping_list_items_from_recipe( + self, list_id: UUID4, recipe_id: UUID4, scale: float = 1 + ) -> list[ShoppingListItemCreate]: + """Generates a list of new list items based on a recipe""" + + recipe = self.repos.recipes.get_one(recipe_id, "id") if not recipe: raise UnexpectedNone("Recipe not found") - to_create = [] + list_items: list[ShoppingListItemCreate] = [] for ingredient in recipe.recipe_ingredient: - food_id = None - try: - food_id = ingredient.food.id # type: ignore - except AttributeError: - pass + if isinstance(ingredient.food, IngredientFood): + is_food = True + food_id = ingredient.food.id + label_id = ingredient.food.label_id - label_id = None - try: - label_id = ingredient.food.label.id # type: ignore - except AttributeError: - pass + else: + is_food = False + food_id = None + label_id = None - unit_id = None - try: - unit_id = ingredient.unit.id # type: ignore - except AttributeError: - pass + if isinstance(ingredient.unit, IngredientUnit): + unit_id = ingredient.unit.id - to_create.append( - ShoppingListItemCreate( - shopping_list_id=list_id, - is_food=not recipe.settings.disable_amount if recipe.settings else False, - food_id=food_id, - unit_id=unit_id, - quantity=ingredient.quantity * recipe_increment if ingredient.quantity else 0, - note=ingredient.note, - label_id=label_id, - recipe_id=recipe_id, - recipe_references=[ - ShoppingListItemRecipeRef( - recipe_id=recipe_id, recipe_quantity=ingredient.quantity, recipe_scale=recipe_increment - ) - ], - ) + else: + unit_id = None + + new_item = ShoppingListItemCreate( + shopping_list_id=list_id, + is_food=is_food, + note=ingredient.note, + quantity=ingredient.quantity * scale if ingredient.quantity else 0, + food_id=food_id, + label_id=label_id, + unit_id=unit_id, + recipe_references=[ + ShoppingListItemRecipeRefCreate( + recipe_id=recipe.id, recipe_quantity=ingredient.quantity, recipe_scale=scale + ) + ], ) - new_shopping_list_items = [self.repos.group_shopping_list_item.create(item) for item in to_create] + # some recipes have the same ingredient multiple times, so we check to see if we can combine them + merged = False + for existing_item in list_items: + if not self.can_merge(existing_item, new_item): + continue - updated_shopping_list = self.shopping_lists.get_one(list_id) - if not updated_shopping_list: - raise UnexpectedNone("Shopping List not found") + # since this is the same recipe, we combine the quanities, rather than the scales + # all items will have exactly one recipe reference + if ingredient.quantity: + existing_item.quantity += ingredient.quantity + existing_item.recipe_references[0].recipe_quantity += ingredient.quantity # type: ignore - updated_shopping_list_items, deleted_shopping_list_items = self.consolidate_and_save( - updated_shopping_list.list_items, # type: ignore - ) - updated_shopping_list.list_items = updated_shopping_list_items + # merge notes + if existing_item.note != new_item.note: + existing_item.note = " | ".join([note for note in [existing_item.note, new_item.note] if note]) - not_found = True - for refs in updated_shopping_list.recipe_references: - if refs.recipe_id != recipe_id: + merged = True + break + + if not merged: + list_items.append(new_item) + + return list_items + + def add_recipe_ingredients_to_list( + self, list_id: UUID4, recipe_id: UUID4, recipe_increment: float = 1 + ) -> tuple[ShoppingListOut, ShoppingListItemsCollectionOut]: + """ + Adds a recipe's 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) + 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 - refs.recipe_quantity += recipe_increment - not_found = False + ref.recipe_quantity += recipe_increment + ref_merged = True break - if not_found: - updated_shopping_list.recipe_references.append( - ShoppingListItemRecipeRef(recipe_id=recipe_id, recipe_quantity=recipe_increment) # type: ignore + if not ref_merged: + updated_list.recipe_references.append( + ShoppingListItemRecipeRefCreate(recipe_id=recipe_id, recipe_quantity=recipe_increment) # type: ignore ) - updated_shopping_list = self.shopping_lists.update(updated_shopping_list.id, updated_shopping_list) - - """ - There can be overlap between the list item collections, so we de-duplicate the lists. - - First new items are created, then existing items are updated, and finally some items are deleted, - so we can de-duplicate using this logic - """ - new_items_map = {list_item.id: list_item for list_item in new_shopping_list_items} - updated_items_map = {list_item.id: list_item for list_item in updated_shopping_list_items} - deleted_items_map = {list_item.id: list_item for list_item in deleted_shopping_list_items} - - # if the item was created and then updated, replace the create with the update and remove the update - for id in list(updated_items_map.keys()): - if id in new_items_map: - new_items_map[id] = updated_items_map[id] - del updated_items_map[id] - - # if the item was updated and then deleted, remove the update - updated_shopping_list_items = [ - list_item for id, list_item in updated_items_map.items() if id not in deleted_items_map - ] - - # if the item was created and then deleted, remove it from both lists - new_shopping_list_items = [list_item for id, list_item in new_items_map.items() if id not in deleted_items_map] - deleted_shopping_list_items = [ - list_item for id, list_item in deleted_items_map.items() if id not in new_items_map - ] - - return updated_shopping_list, new_shopping_list_items, updated_shopping_list_items, deleted_shopping_list_items + updated_list = self.shopping_lists.update(updated_list.id, updated_list) + return updated_list, item_changes def remove_recipe_ingredients_from_list( self, list_id: UUID4, recipe_id: UUID4, recipe_decrement: float = 1 - ) -> tuple[ShoppingListOut, list[ShoppingListItemOut], list[ShoppingListItemOut]]: + ) -> tuple[ShoppingListOut, ShoppingListItemsCollectionOut]: """ - returns: - - updated_shopping_list - - updated_shopping_list_items - - deleted_shopping_list_items + Removes a recipe's ingredients from a list + + Returns a tuple of: + - Updated Shopping List + - Impacted Shopping List Items """ shopping_list = self.shopping_lists.get_one(list_id) if shopping_list is None: raise UnexpectedNone("Shopping list not found, cannot remove recipe ingredients") - updated_shopping_list_items = [] - deleted_shopping_list_items = [] + update_items: list[ShoppingListItemUpdateBulk] = [] + delete_items: list[UUID4] = [] for item in shopping_list.list_items: found = False @@ -270,10 +393,6 @@ class ShoppingListService: if ref.recipe_scale is None: ref.recipe_scale = 1 - # recipe quantity should never be None, but we check just in case - if ref.recipe_quantity is None: - ref.recipe_quantity = 0 - # Set Quantity if ref.recipe_scale > recipe_decrement: # remove only part of the reference @@ -286,25 +405,33 @@ class ShoppingListService: # Set Reference Scale ref.recipe_scale -= recipe_decrement if ref.recipe_scale <= 0: - # delete the ref from the database and remove it from our list - self.list_item_refs.delete(ref.id) item.recipe_references.remove(ref) found = True break - # If the item was found we need to check its new quantity if found: - if item.quantity <= 0: - self.list_items.delete(item.id) - deleted_shopping_list_items.append(item) + # only remove a 0 quantity item if we removed its last recipe reference + if item.quantity < 0 or (item.quantity == 0 and not item.recipe_references): + delete_items.append(item.id) else: - self.list_items.update(item.id, item) - updated_shopping_list_items.append(item) + update_items.append(item.cast(ShoppingListItemUpdateBulk)) + + response_update = self.bulk_update_items(update_items) + + deleted_item_ids = [item.id for item in response_update.deleted_items] + response_delete = self.bulk_delete_items([id for id in delete_items if id not in deleted_item_ids]) + + items = ShoppingListItemsCollectionOut( + created_items=response_update.created_items + response_delete.created_items, + updated_items=response_update.updated_items + response_delete.updated_items, + deleted_items=response_update.deleted_items + response_delete.deleted_items, + ) # Decrement the list recipe reference count - for recipe_ref in shopping_list.recipe_references: + updated_list = self.shopping_lists.get_one(shopping_list.id) + for recipe_ref in updated_list.recipe_references: # type: ignore if recipe_ref.recipe_id != recipe_id or recipe_ref.recipe_quantity is None: continue @@ -318,8 +445,4 @@ class ShoppingListService: break - return ( - self.shopping_lists.get_one(shopping_list.id), - updated_shopping_list_items, - deleted_shopping_list_items, - ) # type: ignore + return self.shopping_lists.get_one(shopping_list.id), items # type: ignore diff --git a/mealie/services/parser_services/ingredient_parser.py b/mealie/services/parser_services/ingredient_parser.py index f24ed6a869b7..36d56c064385 100644 --- a/mealie/services/parser_services/ingredient_parser.py +++ b/mealie/services/parser_services/ingredient_parser.py @@ -4,6 +4,7 @@ from fractions import Fraction from mealie.core.root_logger import get_logger from mealie.schema.recipe import RecipeIngredient from mealie.schema.recipe.recipe_ingredient import ( + MAX_INGREDIENT_DENOMINATOR, CreateIngredientFood, CreateIngredientUnit, IngredientConfidence, @@ -74,7 +75,9 @@ class NLPParser(ABCIngredientParser): unit=CreateIngredientUnit(name=crf_model.unit), food=CreateIngredientFood(name=crf_model.name), disable_amount=False, - quantity=float(sum(Fraction(s).limit_denominator(32) for s in crf_model.qty.split())), + quantity=float( + sum(Fraction(s).limit_denominator(MAX_INGREDIENT_DENOMINATOR) for s in crf_model.qty.split()) + ), ) except Exception as e: logger.error(f"Failed to parse ingredient: {crf_model}: {e}") diff --git a/tests/fixtures/fixture_shopping_lists.py b/tests/fixtures/fixture_shopping_lists.py index 9d57c652524f..933dae4c0291 100644 --- a/tests/fixtures/fixture_shopping_lists.py +++ b/tests/fixtures/fixture_shopping_lists.py @@ -17,9 +17,7 @@ def create_item(list_id: UUID4) -> dict: "note": random_string(10), "quantity": 1, "unit_id": None, - "unit": None, "food_id": None, - "food": None, "recipe_id": None, "label_id": None, } @@ -75,7 +73,7 @@ def list_with_items(database: AllRepositories, unique_user: TestUser): ) # refresh model - list_model = database.group_shopping_lists.get_one(list_model.id) + list_model = database.group_shopping_lists.get_one(list_model.id) # type: ignore yield list_model diff --git a/tests/integration_tests/user_group_tests/test_group_shopping_list_items.py b/tests/integration_tests/user_group_tests/test_group_shopping_list_items.py index ef1fdad936f7..df48510dbd20 100644 --- a/tests/integration_tests/user_group_tests/test_group_shopping_list_items.py +++ b/tests/integration_tests/user_group_tests/test_group_shopping_list_items.py @@ -1,31 +1,22 @@ import random +from math import ceil, floor from uuid import uuid4 -import pytest from fastapi.testclient import TestClient from pydantic import UUID4 from mealie.schema.group.group_shopping_list import ShoppingListItemOut, ShoppingListOut from tests import utils from tests.utils import api_routes -from tests.utils.factories import random_string +from tests.utils.factories import random_int, random_string from tests.utils.fixture_schemas import TestUser def create_item(list_id: UUID4) -> dict: return { "shopping_list_id": str(list_id), - "checked": False, - "position": 0, - "is_food": False, "note": random_string(10), - "quantity": 1, - "unit_id": None, - "unit": None, - "food_id": None, - "food": None, - "recipe_id": None, - "label_id": None, + "quantity": random_int(1, 10), } @@ -49,9 +40,10 @@ def test_shopping_list_items_create_one( response = api_client.post(api_routes.groups_shopping_items, json=item, headers=unique_user.token) as_json = utils.assert_derserialize(response, 201) + assert len(as_json["createdItems"]) == 1 # Test Item is Getable - created_item_id = as_json["id"] + created_item_id = as_json["createdItems"][0]["id"] response = api_client.get(api_routes.groups_shopping_items_item_id(created_item_id), headers=unique_user.token) as_json = utils.assert_derserialize(response, 200) @@ -64,10 +56,39 @@ def test_shopping_list_items_create_one( assert len(response_list["listItems"]) == 1 - # Check Item Id's + # Check Item Ids assert response_list["listItems"][0]["id"] == created_item_id +def test_shopping_list_items_create_many( + api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut +) -> None: + items = [create_item(shopping_list.id) for _ in range(10)] + + response = api_client.post(api_routes.groups_shopping_items_create_bulk, json=items, headers=unique_user.token) + as_json = utils.assert_derserialize(response, 201) + assert len(as_json["createdItems"]) == len(items) + assert len(as_json["updatedItems"]) == 0 + assert len(as_json["deletedItems"]) == 0 + + # test items in list + created_item_ids = [item["id"] for item in as_json["createdItems"]] + response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + + # make sure the list is the correct size + assert len(as_json["listItems"]) == len(items) + + for item in as_json["listItems"]: + # Ensure List Id is Set + assert item["shoppingListId"] == str(shopping_list.id) + assert item["id"] in created_item_ids + created_item_ids.remove(item["id"]) + + # make sure we found all items + assert not created_item_ids + + def test_shopping_list_items_get_one( api_client: TestClient, unique_user: TestUser, @@ -103,7 +124,82 @@ def test_shopping_list_items_update_one( api_routes.groups_shopping_items_item_id(item.id), json=update_data, headers=unique_user.token ) item_json = utils.assert_derserialize(response, 200) - assert item_json["note"] == update_data["note"] + + assert len(item_json["createdItems"]) == 0 + assert len(item_json["updatedItems"]) == 1 + assert len(item_json["deletedItems"]) == 0 + assert item_json["updatedItems"][0]["note"] == update_data["note"] + assert item_json["updatedItems"][0]["quantity"] == update_data["quantity"] + + # make sure the list didn't change sizes + response = api_client.get(api_routes.groups_shopping_lists_item_id(list_with_items.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + assert len(as_json["listItems"]) == len(list_with_items.list_items) + + +def test_shopping_list_items_update_many( + api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut +) -> None: + # create a bunch of items + items = [create_item(shopping_list.id) for _ in range(10)] + for item in items: + item["quantity"] += 10 + + response = api_client.post(api_routes.groups_shopping_items_create_bulk, json=items, headers=unique_user.token) + as_json = utils.assert_derserialize(response, 201) + assert len(as_json["createdItems"]) == len(items) + + # update the items and compare values + item_quantity_map = {} + for update_item in as_json["createdItems"]: + update_item["quantity"] += random_int(-5, 5) + item_quantity_map[update_item["id"]] = update_item["quantity"] + + response = api_client.put(api_routes.groups_shopping_items, json=as_json["createdItems"], headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + assert len(as_json["updatedItems"]) == len(items) + + for updated_item in as_json["updatedItems"]: + assert item_quantity_map[updated_item["id"]] == updated_item["quantity"] + + # make sure the list didn't change sizes + response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + assert len(as_json["listItems"]) == len(items) + + +def test_shopping_list_items_update_many_reorder( + api_client: TestClient, + unique_user: TestUser, + list_with_items: ShoppingListOut, +) -> None: + list_items = list_with_items.list_items + + # reorder list in random order + random.shuffle(list_items) + + # update item posiitons and serialize + as_dict = [] + for i, item in enumerate(list_items): + item.position = i + item_dict = item.dict(by_alias=True) + item_dict["shoppingListId"] = str(list_with_items.id) + item_dict["id"] = str(item.id) + as_dict.append(item_dict) + + # update list + # the default serializer fails on certain complex objects, so we use FastAPI's serializer first + as_dict = utils.jsonify(as_dict) + response = api_client.put(api_routes.groups_shopping_items, json=as_dict, headers=unique_user.token) + assert response.status_code == 200 + + # retrieve list and check positions against list + response = api_client.get(api_routes.groups_shopping_lists_item_id(list_with_items.id), headers=unique_user.token) + response_list = utils.assert_derserialize(response, 200) + + for i, item_data in enumerate(response_list["listItems"]): + assert item_data["position"] == i + assert item_data["id"] == str(list_items[i].id) def test_shopping_list_items_delete_one( @@ -122,44 +218,6 @@ def test_shopping_list_items_delete_one( assert response.status_code == 404 -def test_shopping_list_items_update_many(api_client: TestClient, unique_user: TestUser) -> None: - assert True - - -def test_shopping_list_items_update_many_reorder( - api_client: TestClient, - unique_user: TestUser, - list_with_items: ShoppingListOut, -) -> None: - list_items = list_with_items.list_items - - # reorder list in random order - random.shuffle(list_items) - - # update List posiitons and serialize - as_dict = [] - for i, item in enumerate(list_items): - item.position = i - item_dict = item.dict(by_alias=True) - item_dict["shoppingListId"] = str(list_with_items.id) - item_dict["id"] = str(item.id) - as_dict.append(item_dict) - - # update list - # the default serializer fails on certain complex objects, so we use FastAPI's serliazer first - as_dict = utils.jsonify(as_dict) - response = api_client.put(api_routes.groups_shopping_items, json=as_dict, headers=unique_user.token) - assert response.status_code == 200 - - # retrieve list and check positions against list - response = api_client.get(api_routes.groups_shopping_lists_item_id(list_with_items.id), headers=unique_user.token) - response_list = utils.assert_derserialize(response, 200) - - for i, item_data in enumerate(response_list["listItems"]): - assert item_data["position"] == i - assert item_data["id"] == str(list_items[i].id) - - def test_shopping_list_items_update_many_consolidates_common_items( api_client: TestClient, unique_user: TestUser, @@ -189,14 +247,250 @@ def test_shopping_list_items_update_many_consolidates_common_items( assert response_list["listItems"][0]["note"] == master_note -@pytest.mark.skip("TODO: Implement") -def test_shopping_list_items_update_many_remove_recipe_with_other_items( - api_client: TestClient, - unique_user: TestUser, - list_with_items: ShoppingListOut, -) -> None: - # list_items = list_with_items.list_items - pass +def test_shopping_list_items_add_mergeable( + api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut +): + # add a bunch of items that can be consolidated + items = [create_item(shopping_list.id) for _ in range(5)] + + common_note = random_string() + duplicate_items = [create_item(shopping_list.id) for _ in range(5)] + for item in duplicate_items: + item["note"] = common_note + + merged_qty = sum([item["quantity"] for item in duplicate_items]) # type: ignore + + response = api_client.post( + api_routes.groups_shopping_items_create_bulk, json=items + duplicate_items, headers=unique_user.token + ) + as_json = utils.assert_derserialize(response, 201) + assert len(as_json["createdItems"]) == len(items) + 1 + assert len(as_json["updatedItems"]) == 0 + assert len(as_json["deletedItems"]) == 0 + + found = False + for item in as_json["createdItems"]: + if item["note"] == common_note: + assert item["quantity"] == merged_qty + found = True + break + + assert found + + # add more items that can be merged into the existing items + item_to_merge_into = random.choice(as_json["createdItems"]) + new_item = create_item(shopping_list.id) + new_item["note"] = item_to_merge_into["note"] + updated_quantity = new_item["quantity"] + item_to_merge_into["quantity"] + + response = api_client.post(api_routes.groups_shopping_items, json=new_item, headers=unique_user.token) + item_json = utils.assert_derserialize(response, 201) + + # we should have received an updated item, not a created item + assert len(item_json["createdItems"]) == 0 + assert len(item_json["updatedItems"]) == 1 + assert len(item_json["deletedItems"]) == 0 + assert item_json["updatedItems"][0]["id"] == item_to_merge_into["id"] + assert item_json["updatedItems"][0]["quantity"] == updated_quantity + + # fetch the list and make sure we have the correct number of items + response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) + list_json = utils.assert_derserialize(response, 200) + assert len(list_json["listItems"]) == len(as_json["createdItems"]) + + +def test_shopping_list_items_update_mergable( + api_client: TestClient, unique_user: TestUser, list_with_items: ShoppingListOut +): + # update every other item so it merges into the previous item + for i, item in enumerate(list_with_items.list_items): + if not i % 2: + continue + + item.note = list_with_items.list_items[i - 1].note + + payload = utils.jsonify([item.dict() for item in list_with_items.list_items]) + response = api_client.put(api_routes.groups_shopping_items, json=payload, headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + + assert len(as_json["createdItems"]) == 0 + assert len(as_json["updatedItems"]) == ceil(len(list_with_items.list_items) / 2) + assert len(as_json["deletedItems"]) == floor(len(list_with_items.list_items) / 2) + + # check that every other item was updated, and its quantity matches the sum of itself and the previous item + for i, item in enumerate(list_with_items.list_items): + if not i % 2: + continue + + assert ( + as_json["updatedItems"][floor(i / 2)]["quantity"] + == item.quantity + list_with_items.list_items[i - 1].quantity + ) + + # confirm the number of items on the list matches + response = api_client.get(api_routes.groups_shopping_lists_item_id(list_with_items.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + updated_list_items = as_json["listItems"] + assert len(updated_list_items) == ceil(len(list_with_items.list_items) / 2) + + # update two of the items so they merge into each other + new_note = random_string() + items_to_merge = random.sample(updated_list_items, 2) + for item_data in items_to_merge: + item_data["note"] = new_note + + merged_quantity = sum([item["quantity"] for item in items_to_merge]) + + payload = utils.jsonify(items_to_merge) + response = api_client.put(api_routes.groups_shopping_items, json=payload, headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + + assert len(as_json["createdItems"]) == 0 + assert len(as_json["updatedItems"]) == 1 + assert len(as_json["deletedItems"]) == 1 + assert as_json["deletedItems"][0]["id"] in [item["id"] for item in items_to_merge] + + found = False + for item_data in as_json["updatedItems"]: + if item_data["id"] not in [item["id"] for item in items_to_merge]: + continue + + assert item_data["quantity"] == merged_quantity + found = True + break + + assert found + + +def test_shopping_list_items_checked_off( + api_client: TestClient, unique_user: TestUser, list_with_items: ShoppingListOut +): + # rename an item to match another item and check it off, and make sure it does not affect the other item + checked_item, reference_item = random.sample(list_with_items.list_items, 2) + checked_item.note = reference_item.note + checked_item.checked = True + + response = api_client.put( + api_routes.groups_shopping_items_item_id(checked_item.id), + json=utils.jsonify(checked_item.dict()), + headers=unique_user.token, + ) + + as_json = utils.assert_derserialize(response, 200) + assert len(as_json["createdItems"]) == 0 + assert len(as_json["updatedItems"]) == 1 + assert len(as_json["deletedItems"]) == 0 + updated_item = as_json["updatedItems"][0] + assert updated_item["checked"] + + # get the reference item and make sure it didn't change + response = api_client.get(api_routes.groups_shopping_items_item_id(reference_item.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + reference_item_get = ShoppingListItemOut.parse_obj(as_json) + + assert reference_item_get.id == reference_item.id + assert reference_item_get.shopping_list_id == reference_item.shopping_list_id + assert reference_item_get.note == reference_item.note + assert reference_item_get.quantity == reference_item.quantity + assert reference_item_get.checked == reference_item.checked + + # rename an item to match another item and check both off, and make sure they are not merged + response = api_client.get(api_routes.groups_shopping_lists_item_id(list_with_items.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + updated_list = ShoppingListOut.parse_obj(as_json) + + item_1, item_2 = random.sample(updated_list.list_items, 2) + item_1.checked = True + item_2.checked = True + item_2.note = item_1.note + + response = api_client.put( + api_routes.groups_shopping_items, + json=utils.jsonify([item_1.dict(), item_2.dict()]), + headers=unique_user.token, + ) + + as_json = utils.assert_derserialize(response, 200) + assert len(as_json["createdItems"]) == 0 + assert len(as_json["updatedItems"]) == 2 + assert len(as_json["deletedItems"]) == 0 + + updated_items_map = {item["id"]: item for item in as_json["updatedItems"]} + for item in [item_1, item_2]: + updated_item_data = updated_items_map[str(item.id)] + assert item.note == updated_item_data["note"] + assert item.quantity == updated_item_data["quantity"] + assert updated_item_data["checked"] + + +def test_shopping_list_items_with_zero_quantity( + api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut +): + # add a bunch of items, some with zero quantity, and make sure they persist + normal_items = [create_item(shopping_list.id) for _ in range(10)] + zero_qty_items = [create_item(shopping_list.id) for _ in range(10)] + for item in zero_qty_items: + item["quantity"] = 0 + + response = api_client.post( + api_routes.groups_shopping_items_create_bulk, json=normal_items + zero_qty_items, headers=unique_user.token + ) + as_json = utils.assert_derserialize(response, 201) + assert len(as_json["createdItems"]) == len(normal_items + zero_qty_items) + + # confirm the number of items on the list matches + response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + created_items = as_json["listItems"] + assert len(created_items) == len(normal_items + zero_qty_items) + + # add another zero quantity item so it merges into the existing item + new_item_to_merge = create_item(shopping_list.id) + new_item_to_merge["quantity"] = 0 + target_item = random.choice(created_items) + new_item_to_merge["note"] = target_item["note"] + + response = api_client.post(api_routes.groups_shopping_items, json=new_item_to_merge, headers=unique_user.token) + as_json = utils.assert_derserialize(response, 201) + assert len(as_json["createdItems"]) == 0 + assert len(as_json["updatedItems"]) == 1 + assert len(as_json["deletedItems"]) == 0 + + updated_item = as_json["updatedItems"][0] + assert updated_item["id"] == target_item["id"] + assert updated_item["note"] == target_item["note"] + assert updated_item["quantity"] == target_item["quantity"] + + # confirm the number of items on the list stayed the same + response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + assert len(as_json["listItems"]) == len(normal_items + zero_qty_items) + + # update an existing item to zero quantity and make sure it merges into the existing item + update_item_to_merge, target_item = random.sample(as_json["listItems"], 2) + update_item_to_merge["note"] = target_item["note"] + update_item_to_merge["quantity"] = 0 + + response = api_client.put( + api_routes.groups_shopping_items_item_id(update_item_to_merge["id"]), + json=update_item_to_merge, + headers=unique_user.token, + ) + as_json = utils.assert_derserialize(response, 200) + assert len(as_json["createdItems"]) == 0 + assert len(as_json["updatedItems"]) == 1 + assert len(as_json["deletedItems"]) == 1 + assert as_json["deletedItems"][0]["id"] == update_item_to_merge["id"] + + updated_item = as_json["updatedItems"][0] + assert updated_item["id"] == target_item["id"] + assert updated_item["note"] == target_item["note"] + assert updated_item["quantity"] == target_item["quantity"] + + # confirm the number of items on the list shrunk by one + response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + assert len(as_json["listItems"]) == len(normal_items + zero_qty_items) - 1 def test_shopping_list_item_extras( @@ -213,7 +507,8 @@ def test_shopping_list_item_extras( new_item_data["extras"] = {key_str_1: val_str_1} response = api_client.post(api_routes.groups_shopping_items, json=new_item_data, headers=unique_user.token) - item_as_json = utils.assert_derserialize(response, 201) + collection = utils.assert_derserialize(response, 201) + item_as_json = collection["createdItems"][0] # make sure the extra persists extras = item_as_json["extras"] @@ -226,7 +521,8 @@ def test_shopping_list_item_extras( response = api_client.put( api_routes.groups_shopping_items_item_id(item_as_json["id"]), json=item_as_json, headers=unique_user.token ) - item_as_json = utils.assert_derserialize(response, 200) + collection = utils.assert_derserialize(response, 200) + item_as_json = collection["updatedItems"][0] # make sure both the new extra and original extra persist extras = item_as_json["extras"] diff --git a/tests/integration_tests/user_group_tests/test_group_shopping_lists.py b/tests/integration_tests/user_group_tests/test_group_shopping_lists.py index b82b90fb358f..968df968cb0e 100644 --- a/tests/integration_tests/user_group_tests/test_group_shopping_lists.py +++ b/tests/integration_tests/user_group_tests/test_group_shopping_lists.py @@ -91,7 +91,6 @@ def test_shopping_lists_add_recipe( recipe_ingredient_only: Recipe, ): sample_list = random.choice(shopping_lists) - recipe = recipe_ingredient_only response = api_client.post( @@ -99,24 +98,185 @@ def test_shopping_lists_add_recipe( ) assert response.status_code == 200 - # Get List and Check for Ingredients + # get list and verify items against ingredients + response = api_client.get(api_routes.groups_shopping_lists_item_id(sample_list.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + assert len(as_json["listItems"]) == len(recipe.recipe_ingredient) + + known_ingredients = {ingredient.note: ingredient for ingredient in recipe.recipe_ingredient} + 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) == 1 + assert refs[0]["recipeId"] == str(recipe.id) + assert refs[0]["recipeQuantity"] == 1 + + # add the recipe again and check the resulting items + response = api_client.post( + api_routes.groups_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id), headers=unique_user.token + ) + assert response.status_code == 200 response = api_client.get(api_routes.groups_shopping_lists_item_id(sample_list.id), headers=unique_user.token) as_json = utils.assert_derserialize(response, 200) - assert len(as_json["listItems"]) == len(recipe.recipe_ingredient) - known_ingredients = [ingredient.note for ingredient in recipe.recipe_ingredient] - for item in as_json["listItems"]: assert item["note"] in known_ingredients - # Check Recipe Reference was added with quantity 1 - refs = item["recipeReferences"] + ingredient = known_ingredients[item["note"]] + assert item["quantity"] == (ingredient.quantity or 0) * 2 + refs = as_json["recipeReferences"] assert len(refs) == 1 - assert refs[0]["recipeId"] == str(recipe.id) + assert refs[0]["recipeQuantity"] == 2 + + +def test_shopping_lists_add_one_with_zero_quantity( + api_client: TestClient, + unique_user: TestUser, + shopping_lists: list[ShoppingListOut], +): + shopping_list = random.choice(shopping_lists) + + # build a recipe that has some ingredients with a null quantity + response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token) + recipe_slug = utils.assert_derserialize(response, 201) + + response = api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token) + recipe_data = utils.assert_derserialize(response, 200) + + ingredient_1 = {"quantity": random_int(1, 10), "note": random_string()} + ingredient_2 = {"quantity": random_int(1, 10), "note": random_string()} + ingredient_3_null_qty = {"quantity": None, "note": random_string()} + + recipe_data["recipeIngredient"] = [ingredient_1, ingredient_2, ingredient_3_null_qty] + response = api_client.put(f"{api_routes.recipes}/{recipe_slug}", json=recipe_data, headers=unique_user.token) + utils.assert_derserialize(response, 200) + + recipe = Recipe.parse_raw(api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token).content) + assert recipe.id + assert len(recipe.recipe_ingredient) == 3 + + # add the recipe to the list and make sure there are three list items + response = api_client.post( + api_routes.groups_shopping_lists_item_id_recipe_recipe_id(shopping_list.id, recipe.id), + headers=unique_user.token, + ) + + response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) + shopping_list_out = ShoppingListOut.parse_obj(utils.assert_derserialize(response, 200)) + + assert len(shopping_list_out.list_items) == 3 + + found = False + for item in shopping_list_out.list_items: + if item.note != ingredient_3_null_qty["note"]: + continue + + found = True + assert item.quantity == 0 + + assert found + + +def test_shopping_list_ref_removes_itself( + api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut, recipe_ingredient_only: Recipe +): + # 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.groups_shopping_lists_item_id_recipe_recipe_id(shopping_list.id, recipe.id), + headers=unique_user.token, + ) + utils.assert_derserialize(response, 200) + + response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) + shopping_list_json = utils.assert_derserialize(response, 200) + assert len(shopping_list_json["listItems"]) == len(recipe.recipe_ingredient) + assert len(shopping_list_json["recipeReferences"]) == 1 + + for item in shopping_list_json["listItems"]: + item["checked"] = True + + response = api_client.put( + api_routes.groups_shopping_items, json=shopping_list_json["listItems"], headers=unique_user.token + ) + utils.assert_derserialize(response, 200) + + response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) + shopping_list_json = utils.assert_derserialize(response, 200) + assert len(shopping_list_json["recipeReferences"]) == 0 + + +def test_shopping_lists_add_recipe_with_merge( + api_client: TestClient, + unique_user: TestUser, + shopping_lists: list[ShoppingListOut], +): + shopping_list = random.choice(shopping_lists) + + # build a recipe that has some ingredients more than once + response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token) + recipe_slug = utils.assert_derserialize(response, 201) + + response = api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token) + recipe_data = utils.assert_derserialize(response, 200) + + ingredient_1 = {"quantity": random_int(1, 10), "note": random_string()} + ingredient_2 = {"quantity": random_int(1, 10), "note": random_string()} + ingredient_duplicate_1 = {"quantity": random_int(1, 10), "note": random_string()} + ingredient_duplicate_2 = {"quantity": random_int(1, 10), "note": ingredient_duplicate_1["note"]} + + recipe_data["recipeIngredient"] = [ingredient_1, ingredient_2, ingredient_duplicate_1, ingredient_duplicate_2] + response = api_client.put(f"{api_routes.recipes}/{recipe_slug}", json=recipe_data, headers=unique_user.token) + utils.assert_derserialize(response, 200) + + recipe = Recipe.parse_raw(api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token).content) + assert recipe.id + assert len(recipe.recipe_ingredient) == 4 + + # 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.groups_shopping_lists_item_id_recipe_recipe_id(shopping_list.id, recipe.id), + headers=unique_user.token, + ) + + response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) + shopping_list_out = ShoppingListOut.parse_obj(utils.assert_derserialize(response, 200)) + + assert len(shopping_list_out.list_items) == 3 + + found_item_1 = False + found_item_2 = False + found_duplicate_item = False + for list_item in shopping_list_out.list_items: + assert len(list_item.recipe_references) == 1 + + ref = list_item.recipe_references[0] + assert ref.recipe_scale == 1 + assert ref.recipe_quantity == list_item.quantity + + if list_item.note == ingredient_1["note"]: + assert list_item.quantity == ingredient_1["quantity"] + found_item_1 = True + + elif list_item.note == ingredient_2["note"]: + assert list_item.quantity == ingredient_2["quantity"] + found_item_2 = True + + elif list_item.note == ingredient_duplicate_1["note"]: + combined_quantity = ingredient_duplicate_1["quantity"] + ingredient_duplicate_2["quantity"] # type: ignore + assert list_item.quantity == combined_quantity + found_duplicate_item = True + + assert all([found_item_1, found_item_2, found_duplicate_item]) def test_shopping_list_add_recipe_scale( @@ -182,32 +342,49 @@ def test_shopping_lists_remove_recipe( recipe_ingredient_only: Recipe, ): sample_list = random.choice(shopping_lists) - recipe = recipe_ingredient_only + # add two instances of the recipe + payload = {"recipeIncrementQuantity": 2} response = api_client.post( - api_routes.groups_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id), headers=unique_user.token + api_routes.groups_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id), + json=payload, + headers=unique_user.token, ) assert response.status_code == 200 - # Get List and Check for Ingredients - response = api_client.get(api_routes.groups_shopping_lists_item_id(sample_list.id), headers=unique_user.token) - as_json = utils.assert_derserialize(response, 200) - - assert len(as_json["listItems"]) == len(recipe.recipe_ingredient) - - known_ingredients = [ingredient.note for ingredient in recipe.recipe_ingredient] - - for item in as_json["listItems"]: - assert item["note"] in known_ingredients - - # Remove Recipe + # remove one instance of the recipe response = api_client.post( api_routes.groups_shopping_lists_item_id_recipe_recipe_id_delete(sample_list.id, recipe.id), headers=unique_user.token, ) + assert response.status_code == 200 + + # get list and verify items against ingredients + response = api_client.get(api_routes.groups_shopping_lists_item_id(sample_list.id), headers=unique_user.token) + as_json = utils.assert_derserialize(response, 200) + assert len(as_json["listItems"]) == len(recipe.recipe_ingredient) + + known_ingredients = {ingredient.note: ingredient for ingredient in recipe.recipe_ingredient} + 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 reduced to 1 + refs = as_json["recipeReferences"] + assert len(refs) == 1 + assert refs[0]["recipeId"] == str(recipe.id) + assert refs[0]["recipeQuantity"] == 1 + + # remove the recipe again and check if the list is empty + response = api_client.post( + api_routes.groups_shopping_lists_item_id_recipe_recipe_id_delete(sample_list.id, recipe.id), + headers=unique_user.token, + ) + assert response.status_code == 200 - # Get List and Check for Ingredients response = api_client.get(api_routes.groups_shopping_lists_item_id(sample_list.id), headers=unique_user.token) as_json = utils.assert_derserialize(response, 200) assert len(as_json["listItems"]) == 0 @@ -221,7 +398,6 @@ def test_shopping_lists_remove_recipe_multiple_quantity( recipe_ingredient_only: Recipe, ): sample_list = random.choice(shopping_lists) - recipe = recipe_ingredient_only for _ in range(3): @@ -357,13 +533,14 @@ def test_recipe_decrement_max( # next add a little bit more of one item item_additional_quantity = random_int(1, 10) - item_json = as_json["listItems"][0] + item_json = random.choice(as_json["listItems"]) item_json["quantity"] += item_additional_quantity response = api_client.put( - api_routes.groups_shopping_items_item_id(item["id"]), json=item_json, headers=unique_user.token + api_routes.groups_shopping_items_item_id(item_json["id"]), json=item_json, headers=unique_user.token ) - item_json = utils.assert_derserialize(response, 200) + as_json = utils.assert_derserialize(response, 200) + item_json = as_json["updatedItems"][0] assert item_json["quantity"] == recipe_scale + item_additional_quantity # now remove way too many instances of the recipe @@ -386,6 +563,105 @@ def test_recipe_decrement_max( assert len(item["recipeReferences"]) == 0 +def test_recipe_manipulation_with_zero_quantities( + api_client: TestClient, + unique_user: TestUser, + shopping_lists: list[ShoppingListOut], +): + shopping_list = random.choice(shopping_lists) + + # create a recipe with one item that has a quantity of zero + response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token) + recipe_slug = utils.assert_derserialize(response, 201) + + response = api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token) + recipe_data = utils.assert_derserialize(response, 200) + + note_with_zero_quantity = random_string() + recipe_data["recipeIngredient"] = [ + {"quantity": random_int(1, 10), "note": random_string()}, + {"quantity": random_int(1, 10), "note": random_string()}, + {"quantity": random_int(1, 10), "note": random_string()}, + {"quantity": 0, "note": note_with_zero_quantity}, + ] + + response = api_client.put(f"{api_routes.recipes}/{recipe_slug}", json=recipe_data, headers=unique_user.token) + utils.assert_derserialize(response, 200) + + recipe = Recipe.parse_raw(api_client.get(f"{api_routes.recipes}/{recipe_slug}", headers=unique_user.token).content) + assert recipe.id + assert len(recipe.recipe_ingredient) == 4 + + # add the recipe to the list twice and make sure the quantity is still zero + response = api_client.post( + api_routes.groups_shopping_lists_item_id_recipe_recipe_id(shopping_list.id, recipe.id), + headers=unique_user.token, + ) + utils.assert_derserialize(response, 200) + + response = api_client.post( + api_routes.groups_shopping_lists_item_id_recipe_recipe_id(shopping_list.id, recipe.id), + headers=unique_user.token, + ) + utils.assert_derserialize(response, 200) + + response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) + updated_list = ShoppingListOut.parse_raw(response.content) + assert len(updated_list.list_items) == 4 + + found = False + for item in updated_list.list_items: + if item.note != note_with_zero_quantity: + continue + + assert item.quantity == 0 + + recipe_ref = item.recipe_references[0] + assert recipe_ref.recipe_scale == 2 + + found = True + break + + if not found: + raise Exception("Did not find item with no quantity in shopping list") + + # remove the recipe once and make sure the item is still on the list + api_client.post( + api_routes.groups_shopping_lists_item_id_recipe_recipe_id_delete(shopping_list.id, recipe.id), + headers=unique_user.token, + ) + + response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) + updated_list = ShoppingListOut.parse_raw(response.content) + assert len(updated_list.list_items) == 4 + + found = False + for item in updated_list.list_items: + if item.note != note_with_zero_quantity: + continue + + assert item.quantity == 0 + + recipe_ref = item.recipe_references[0] + assert recipe_ref.recipe_scale == 1 + + found = True + break + + if not found: + raise Exception("Did not find item with no quantity in shopping list") + + # remove the recipe one more time and make sure the item is gone and the list is empty + api_client.post( + api_routes.groups_shopping_lists_item_id_recipe_recipe_id_delete(shopping_list.id, recipe.id), + headers=unique_user.token, + ) + + response = api_client.get(api_routes.groups_shopping_lists_item_id(shopping_list.id), headers=unique_user.token) + updated_list = ShoppingListOut.parse_raw(response.content) + assert len(updated_list.list_items) == 0 + + def test_shopping_list_extras( api_client: TestClient, unique_user: TestUser, diff --git a/tests/utils/api_routes/__init__.py b/tests/utils/api_routes/__init__.py index 01e40d63ae32..5957dc1a0375 100644 --- a/tests/utils/api_routes/__init__.py +++ b/tests/utils/api_routes/__init__.py @@ -93,6 +93,8 @@ groups_self = "/api/groups/self" """`/api/groups/self`""" groups_shopping_items = "/api/groups/shopping/items" """`/api/groups/shopping/items`""" +groups_shopping_items_create_bulk = "/api/groups/shopping/items/create-bulk" +"""`/api/groups/shopping/items/create-bulk`""" groups_shopping_lists = "/api/groups/shopping/lists" """`/api/groups/shopping/lists`""" groups_statistics = "/api/groups/statistics"