-
+
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"