Refactor Shopping List API (#2021)

* tidied up shopping list item models
redefined recipe refs and updated models
added calculated display attribute to unify shopping list item rendering
added validation to use a food's label if an item's label is null

* fixed schema reference

* refactored shopping list item service
route all operations through one central method to account for edgecases
return item collections for all operations to account for merging
consolidate recipe items before sending them to the shopping list

* made fractions prettier

* replaced redundant display text util

* fixed edgecase for zero quantity items on a recipe

* fix for pre-merging recipe ingredients

* fixed edgecase for merging create_items together

* fixed bug with merged updated items creating dupes

* added test for self-removing recipe ref

* update items are now merged w/ existing items

* refactored service to make it easier to read

* added a lot of tests

* made it so checked items are never merged

* fixed bug with dragging + re-ordering

* fix for postgres cascade issue

* added prevalidator to recipe ref to avoid db error
This commit is contained in:
Michael Genson 2023-01-28 18:45:02 -06:00 committed by GitHub
parent 3415a9c310
commit 617cc1fdfb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1398 additions and 576 deletions

View File

@ -10,7 +10,7 @@
> >
<template #label> <template #label>
<div :class="listItem.checked ? 'strike-through' : ''"> <div :class="listItem.checked ? 'strike-through' : ''">
{{ displayText }} {{ listItem.display }}
</div> </div>
</template> </template>
</v-checkbox> </v-checkbox>
@ -55,10 +55,9 @@
import { defineComponent, computed, ref, useContext } from "@nuxtjs/composition-api"; import { defineComponent, computed, ref, useContext } from "@nuxtjs/composition-api";
import ShoppingListItemEditor from "./ShoppingListItemEditor.vue"; import ShoppingListItemEditor from "./ShoppingListItemEditor.vue";
import MultiPurposeLabel from "./MultiPurposeLabel.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 { MultiPurposeLabelOut } from "~/lib/api/types/labels";
import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe"; import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
import { getDisplayText } from "~/composables/use-display-text";
import { MultiPurposeLabelSummary } from "~/lib/api/types/user"; import { MultiPurposeLabelSummary } from "~/lib/api/types/user";
interface actions { interface actions {
@ -70,7 +69,7 @@ export default defineComponent({
components: { ShoppingListItemEditor, MultiPurposeLabel }, components: { ShoppingListItemEditor, MultiPurposeLabel },
props: { props: {
value: { value: {
type: Object as () => ShoppingListItemCreate, type: Object as () => ShoppingListItemOut,
required: true, required: true,
}, },
labels: { 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 * Gets the label for the shopping list item. Either the label assign to the item
* or the label of the food applied. * or the label of the food applied.
@ -170,7 +165,6 @@ export default defineComponent({
}); });
return { return {
displayText,
updatedLabels, updatedLabels,
save, save,
contextHandler, contextHandler,

View File

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

View File

@ -245,6 +245,9 @@ export interface SetPermissions {
canInvite?: boolean; canInvite?: boolean;
canOrganize?: boolean; canOrganize?: boolean;
} }
export interface ShoppingListAddRecipeParams {
recipeIncrementQuantity?: number;
}
export interface ShoppingListCreate { export interface ShoppingListCreate {
name?: string; name?: string;
extras?: { extras?: {
@ -253,6 +256,20 @@ export interface ShoppingListCreate {
createdAt?: string; createdAt?: string;
updateAt?: 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 { export interface ShoppingListItemCreate {
shoppingListId: string; shoppingListId: string;
checked?: boolean; checked?: boolean;
@ -260,28 +277,38 @@ export interface ShoppingListItemCreate {
isFood?: boolean; isFood?: boolean;
note?: string; note?: string;
quantity?: number; quantity?: number;
unitId?: string;
unit?: IngredientUnit;
foodId?: string; foodId?: string;
food?: IngredientFood;
labelId?: string; labelId?: string;
recipeReferences?: ShoppingListItemRecipeRef[]; unitId?: string;
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
}; };
createdAt?: string; recipeReferences?: ShoppingListItemRecipeRefCreate[];
updateAt?: string;
} }
export interface IngredientUnit { export interface ShoppingListItemRecipeRefCreate {
name: string; recipeId: string;
description?: 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?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
}; };
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
id: string; id: string;
display?: string;
food?: IngredientFood;
label?: MultiPurposeLabelSummary;
unit?: IngredientUnit;
recipeReferences?: ShoppingListItemRecipeRefOut[];
createdAt?: string; createdAt?: string;
updateAt?: string; updateAt?: string;
} }
@ -303,34 +330,30 @@ export interface MultiPurposeLabelSummary {
groupId: string; groupId: string;
id: string; id: string;
} }
export interface ShoppingListItemRecipeRef { export interface IngredientUnit {
recipeId: string; name: string;
recipeQuantity?: number; description?: string;
}
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)[];
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
}; };
fraction?: boolean;
abbreviation?: string;
useAbbreviation?: boolean;
id: string;
createdAt?: string; createdAt?: string;
updateAt?: string; updateAt?: string;
id: string;
label?: MultiPurposeLabelSummary;
} }
export interface ShoppingListItemRecipeRefOut { export interface ShoppingListItemRecipeRefOut {
recipeId: string; recipeId: string;
recipeQuantity?: number; recipeQuantity?: number;
recipeScale?: number;
id: string;
shoppingListItemId: string;
}
export interface ShoppingListItemRecipeRefUpdate {
recipeId: string;
recipeQuantity?: number;
recipeScale?: number;
id: string; id: string;
shoppingListItemId: string; shoppingListItemId: string;
} }
@ -341,19 +364,41 @@ export interface ShoppingListItemUpdate {
isFood?: boolean; isFood?: boolean;
note?: string; note?: string;
quantity?: number; quantity?: number;
unitId?: string;
unit?: IngredientUnit;
foodId?: string; foodId?: string;
food?: IngredientFood;
labelId?: string; labelId?: string;
recipeReferences?: ShoppingListItemRecipeRef[]; unitId?: string;
extras?: { extras?: {
[k: string]: unknown; [k: string]: unknown;
}; };
createdAt?: string; recipeReferences?: (ShoppingListItemRecipeRefCreate | ShoppingListItemRecipeRefUpdate)[];
updateAt?: string; }
/**
* 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; id: string;
} }
/**
* Container for bulk shopping list item changes
*/
export interface ShoppingListItemsCollectionOut {
createdItems?: ShoppingListItemOut[];
updatedItems?: ShoppingListItemOut[];
deletedItems?: ShoppingListItemOut[];
}
export interface ShoppingListOut { export interface ShoppingListOut {
name?: string; name?: string;
extras?: { extras?: {
@ -442,6 +487,9 @@ export interface CreateIngredientFood {
}; };
labelId?: string; labelId?: string;
} }
export interface ShoppingListRemoveRecipeParams {
recipeDecrementQuantity?: number;
}
export interface ShoppingListSave { export interface ShoppingListSave {
name?: string; name?: string;
extras?: { extras?: {

View File

@ -69,6 +69,7 @@ export interface LongLiveTokenOut {
token: string; token: string;
name: string; name: string;
id: number; id: number;
createdAt?: string;
} }
export interface ReadGroupPreferences { export interface ReadGroupPreferences {
privateGroup?: boolean; privateGroup?: boolean;

View File

@ -4,7 +4,7 @@ import {
ShoppingListCreate, ShoppingListCreate,
ShoppingListItemCreate, ShoppingListItemCreate,
ShoppingListItemOut, ShoppingListItemOut,
ShoppingListItemUpdate, ShoppingListItemUpdateBulk,
ShoppingListOut, ShoppingListOut,
ShoppingListUpdate, ShoppingListUpdate,
} from "~/lib/api/types/group"; } from "~/lib/api/types/group";
@ -37,7 +37,7 @@ export class ShoppingListsApi extends BaseCRUDAPI<ShoppingListCreate, ShoppingLi
export class ShoppingListItemsApi extends BaseCRUDAPI< export class ShoppingListItemsApi extends BaseCRUDAPI<
ShoppingListItemCreate, ShoppingListItemCreate,
ShoppingListItemOut, ShoppingListItemOut,
ShoppingListItemUpdate ShoppingListItemUpdateBulk
> { > {
baseRoute = routes.shoppingListItems; baseRoute = routes.shoppingListItems;
itemRoute = routes.shoppingListItemsId; itemRoute = routes.shoppingListItemsId;

View File

@ -10,7 +10,7 @@
<!-- Viewer --> <!-- Viewer -->
<section v-if="!edit" class="py-2"> <section v-if="!edit" class="py-2">
<div v-if="!byLabel"> <div v-if="!byLabel">
<draggable :value="shoppingList.listItems" handle=".handle" @start="loadingCounter += 1" @end="loadingCounter -= 1" @input="updateIndex"> <draggable :value="listItems.unchecked" handle=".handle" @start="loadingCounter += 1" @end="loadingCounter -= 1" @input="updateIndexUnchecked">
<v-lazy v-for="(item, index) in listItems.unchecked" :key="item.id"> <v-lazy v-for="(item, index) in listItems.unchecked" :key="item.id">
<ShoppingListItem <ShoppingListItem
v-model="listItems.unchecked[index]" v-model="listItems.unchecked[index]"
@ -131,7 +131,7 @@
<ShoppingListItem <ShoppingListItem
v-model="listItems.checked[idx]" v-model="listItems.checked[idx]"
class="strike-through-note" class="strike-through-note"
:labels="allLabels" :labels="allLabels || []"
:units="allUnits || []" :units="allUnits || []"
:foods="allFoods || []" :foods="allFoods || []"
@checked="saveListItem" @checked="saveListItem"
@ -196,7 +196,6 @@ import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.
import { ShoppingListItemCreate, ShoppingListItemOut } from "~/lib/api/types/group"; import { ShoppingListItemCreate, ShoppingListItemOut } from "~/lib/api/types/group";
import RecipeList from "~/components/Domain/Recipe/RecipeList.vue"; import RecipeList from "~/components/Domain/Recipe/RecipeList.vue";
import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue"; import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue";
import { getDisplayText } from "~/composables/use-display-text";
import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store"; import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store";
type CopyTypes = "plain" | "markdown"; type CopyTypes = "plain" | "markdown";
@ -313,7 +312,7 @@ export default defineComponent({
return; return;
} }
const text = items.map((itm) => getDisplayText(itm.note, itm.quantity, itm.food, itm.unit)); const text: string[] = items.map((itm) => itm.display || "");
switch (copyType) { switch (copyType) {
case "markdown": case "markdown":
@ -514,7 +513,7 @@ export default defineComponent({
if (item.checked && shoppingList.value.listItems) { if (item.checked && shoppingList.value.listItems) {
const lst = shoppingList.value.listItems.filter((itm) => itm.id !== item.id); const lst = shoppingList.value.listItems.filter((itm) => itm.id !== item.id);
lst.push(item); lst.push(item);
updateIndex(lst); updateListItems();
} }
const { data } = await userApi.shopping.items.updateOne(item.id, item); const { data } = await userApi.shopping.items.updateOne(item.id, item);
@ -553,9 +552,9 @@ export default defineComponent({
isFood: false, isFood: false,
quantity: 1, quantity: 1,
note: "", note: "",
unit: undefined,
food: undefined,
labelId: 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) { 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(); updateListItems();
@ -646,7 +646,7 @@ export default defineComponent({
sortByLabels, sortByLabels,
toggleShowChecked, toggleShowChecked,
uncheckAll, uncheckAll,
updateIndex, updateIndexUnchecked,
allUnits, allUnits,
allFoods, allFoods,
}; };

View File

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from math import ceil from math import ceil
from typing import Any, Generic, TypeVar from typing import Any, Generic, Iterable, TypeVar
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import UUID4, BaseModel from pydantic import UUID4, BaseModel
@ -11,7 +11,11 @@ from sqlalchemy.orm.session import Session
from sqlalchemy.sql import sqltypes from sqlalchemy.sql import sqltypes
from mealie.core.root_logger import get_logger 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 from mealie.schema.response.query_filter import QueryFilter
Schema = TypeVar("Schema", bound=BaseModel) Schema = TypeVar("Schema", bound=BaseModel)
@ -158,7 +162,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
return self.schema.from_orm(new_document) 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 = [] new_documents = []
for document in data: for document in data:
document = document if isinstance(document, dict) else document.dict() 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.add_all(new_documents)
self.session.commit() 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] return [self.schema.from_orm(x) for x in new_documents]
@ -189,6 +195,23 @@ class RepositoryGeneric(Generic[Schema, Model]):
self.session.commit() self.session.commit()
return self.schema.from_orm(entry) 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: 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() 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 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: def delete_all(self) -> None:
self._query().delete() self._query().delete()
self.session.commit() self.session.commit()

View File

@ -1,4 +1,5 @@
from functools import cached_property from functools import cached_property
from typing import Callable
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from pydantic import UUID4 from pydantic import UUID4
@ -11,7 +12,9 @@ from mealie.schema.group.group_shopping_list import (
ShoppingListCreate, ShoppingListCreate,
ShoppingListItemCreate, ShoppingListItemCreate,
ShoppingListItemOut, ShoppingListItemOut,
ShoppingListItemsCollectionOut,
ShoppingListItemUpdate, ShoppingListItemUpdate,
ShoppingListItemUpdateBulk,
ShoppingListOut, ShoppingListOut,
ShoppingListPagination, ShoppingListPagination,
ShoppingListRemoveRecipeParams, ShoppingListRemoveRecipeParams,
@ -26,7 +29,6 @@ from mealie.services.event_bus_service.event_types import (
EventOperation, EventOperation,
EventShoppingListData, EventShoppingListData,
EventShoppingListItemBulkData, EventShoppingListItemBulkData,
EventShoppingListItemData,
EventTypes, EventTypes,
) )
from mealie.services.group_services.shopping_lists import ShoppingListService 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"]) 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) @controller(item_router)
class ShoppingListItemController(BaseCrudController): class ShoppingListItemController(BaseCrudController):
@cached_property @cached_property
@ -51,96 +101,43 @@ class ShoppingListItemController(BaseCrudController):
self.logger, self.logger,
) )
@item_router.put("", response_model=list[ShoppingListItemOut]) @item_router.post("/create-bulk", response_model=ShoppingListItemsCollectionOut, status_code=201)
def update_many(self, data: list[ShoppingListItemUpdate]): def create_many(self, data: list[ShoppingListItemCreate]):
# TODO: Convert to update many with single call items = self.service.bulk_create_items(data)
publish_list_item_events(self.publish_event, items)
return items
all_updates = [] @item_router.post("", response_model=ShoppingListItemsCollectionOut, status_code=201)
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)
def create_one(self, data: ShoppingListItemCreate): def create_one(self, data: ShoppingListItemCreate):
shopping_list_item = self.mixins.create_one(data) return self.create_many([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
@item_router.get("/{item_id}", response_model=ShoppingListItemOut) @item_router.get("/{item_id}", response_model=ShoppingListItemOut)
def get_one(self, item_id: UUID4): def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id) 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): 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: @item_router.delete("", response_model=SuccessResponse)
self.publish_event( def delete_many(self, ids: list[UUID4] = Query(None)):
event_type=EventTypes.shopping_list_updated, items = self.service.bulk_delete_items(ids)
document_data=EventShoppingListItemData( publish_list_item_events(self.publish_event, items)
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}",
),
)
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): def delete_one(self, item_id: UUID4):
shopping_list_item = self.mixins.delete_one(item_id) # type: ignore return self.delete_many([item_id])
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
router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists"]) router = APIRouter(prefix="/groups/shopping/lists", tags=["Group: Shopping Lists"])
@ -223,85 +220,20 @@ class ShoppingListController(BaseCrudController):
def add_recipe_ingredients_to_list( def add_recipe_ingredients_to_list(
self, item_id: UUID4, recipe_id: UUID4, data: ShoppingListAddRecipeParams | None = None self, item_id: UUID4, recipe_id: UUID4, data: ShoppingListAddRecipeParams | None = None
): ):
( shopping_list, items = self.service.add_recipe_ingredients_to_list(
shopping_list,
new_shopping_list_items,
updated_shopping_list_items,
deleted_shopping_list_items,
) = self.service.add_recipe_ingredients_to_list(
item_id, recipe_id, data.recipe_increment_quantity if data else 1 item_id, recipe_id, data.recipe_increment_quantity if data else 1
) )
if new_shopping_list_items: publish_list_item_events(self.publish_event, 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
],
),
)
return shopping_list return shopping_list
@router.post("/{item_id}/recipe/{recipe_id}/delete", response_model=ShoppingListOut) @router.post("/{item_id}/recipe/{recipe_id}/delete", response_model=ShoppingListOut)
def remove_recipe_ingredients_from_list( def remove_recipe_ingredients_from_list(
self, item_id: UUID4, recipe_id: UUID4, data: ShoppingListRemoveRecipeParams | None = None self, item_id: UUID4, recipe_id: UUID4, data: ShoppingListRemoveRecipeParams | None = None
): ):
( shopping_list, items = self.service.remove_recipe_ingredients_from_list(
shopping_list,
updated_shopping_list_items,
deleted_shopping_list_items,
) = self.service.remove_recipe_ingredients_from_list(
item_id, recipe_id, data.recipe_decrement_quantity if data else 1 item_id, recipe_id, data.recipe_decrement_quantity if data else 1
) )
if updated_shopping_list_items: publish_list_item_events(self.publish_event, 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
],
),
)
return shopping_list return shopping_list

View File

@ -42,3 +42,13 @@ class MealieModel(BaseModel):
for field in src.__fields__: for field in src.__fields__:
if field in self.__fields__: if field in self.__fields__:
setattr(self, field, getattr(src, field)) 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)

View File

@ -14,36 +14,50 @@ from .group_events import (
from .group_exports import GroupDataExport from .group_exports import GroupDataExport
from .group_migration import DataMigrationCreate, SupportedMigrations from .group_migration import DataMigrationCreate, SupportedMigrations
from .group_permissions import SetPermissions 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_seeder import SeederConfig
from .group_shopping_list import ( from .group_shopping_list import (
ShoppingListAddRecipeParams,
ShoppingListCreate, ShoppingListCreate,
ShoppingListItemBase,
ShoppingListItemCreate, ShoppingListItemCreate,
ShoppingListItemOut, ShoppingListItemOut,
ShoppingListItemRecipeRef, ShoppingListItemRecipeRefCreate,
ShoppingListItemRecipeRefOut, ShoppingListItemRecipeRefOut,
ShoppingListItemRecipeRefUpdate,
ShoppingListItemsCollectionOut,
ShoppingListItemUpdate, ShoppingListItemUpdate,
ShoppingListItemUpdateBulk,
ShoppingListOut, ShoppingListOut,
ShoppingListPagination, ShoppingListPagination,
ShoppingListRecipeRefOut, ShoppingListRecipeRefOut,
ShoppingListRemoveRecipeParams,
ShoppingListSave, ShoppingListSave,
ShoppingListSummary, ShoppingListSummary,
ShoppingListUpdate, ShoppingListUpdate,
) )
from .group_statistics import GroupStatistics, GroupStorage from .group_statistics import GroupStatistics, GroupStorage
from .invite_token import CreateInviteToken, EmailInitationResponse, EmailInvitation, ReadInviteToken, SaveInviteToken from .invite_token import (
from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType CreateInviteToken,
EmailInitationResponse,
EmailInvitation,
ReadInviteToken,
SaveInviteToken,
)
from .webhook import (
CreateWebhook,
ReadWebhook,
SaveWebhook,
WebhookPagination,
WebhookType,
)
__all__ = [ __all__ = [
"CreateGroupPreferences", "GroupAdminUpdate",
"ReadGroupPreferences",
"UpdateGroupPreferences",
"GroupDataExport",
"CreateWebhook",
"ReadWebhook",
"SaveWebhook",
"WebhookPagination",
"WebhookType",
"GroupEventNotifierCreate", "GroupEventNotifierCreate",
"GroupEventNotifierOptions", "GroupEventNotifierOptions",
"GroupEventNotifierOptionsOut", "GroupEventNotifierOptionsOut",
@ -53,23 +67,32 @@ __all__ = [
"GroupEventNotifierSave", "GroupEventNotifierSave",
"GroupEventNotifierUpdate", "GroupEventNotifierUpdate",
"GroupEventPagination", "GroupEventPagination",
"GroupDataExport",
"DataMigrationCreate", "DataMigrationCreate",
"SupportedMigrations", "SupportedMigrations",
"SetPermissions",
"CreateGroupPreferences",
"ReadGroupPreferences",
"UpdateGroupPreferences",
"SeederConfig", "SeederConfig",
"ShoppingListAddRecipeParams",
"ShoppingListCreate", "ShoppingListCreate",
"ShoppingListItemBase",
"ShoppingListItemCreate", "ShoppingListItemCreate",
"ShoppingListItemOut", "ShoppingListItemOut",
"ShoppingListItemRecipeRef", "ShoppingListItemRecipeRefCreate",
"ShoppingListItemRecipeRefOut", "ShoppingListItemRecipeRefOut",
"ShoppingListItemRecipeRefUpdate",
"ShoppingListItemsCollectionOut",
"ShoppingListItemUpdate", "ShoppingListItemUpdate",
"ShoppingListItemUpdateBulk",
"ShoppingListOut", "ShoppingListOut",
"ShoppingListPagination", "ShoppingListPagination",
"ShoppingListRecipeRefOut", "ShoppingListRecipeRefOut",
"ShoppingListRemoveRecipeParams",
"ShoppingListSave", "ShoppingListSave",
"ShoppingListSummary", "ShoppingListSummary",
"ShoppingListUpdate", "ShoppingListUpdate",
"GroupAdminUpdate",
"SetPermissions",
"GroupStatistics", "GroupStatistics",
"GroupStorage", "GroupStorage",
"CreateInviteToken", "CreateInviteToken",
@ -77,4 +100,9 @@ __all__ = [
"EmailInvitation", "EmailInvitation",
"ReadInviteToken", "ReadInviteToken",
"SaveInviteToken", "SaveInviteToken",
"CreateWebhook",
"ReadWebhook",
"SaveWebhook",
"WebhookPagination",
"WebhookType",
] ]

View File

@ -1,35 +1,50 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from fractions import Fraction
from pydantic import UUID4 from pydantic import UUID4, validator
from pydantic.utils import GetterDict from pydantic.utils import GetterDict
from mealie.db.models.group.shopping_list import ShoppingList, ShoppingListItem from mealie.db.models.group.shopping_list import ShoppingList, ShoppingListItem
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
from mealie.schema._mealie.types import NoneFloat 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 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_id: UUID4
recipe_quantity: NoneFloat = 0 recipe_quantity: float = 0
"""the quantity of this item in a single recipe (scale == 1)""" """the quantity of this item in a single recipe (scale == 1)"""
recipe_scale: NoneFloat = 1 recipe_scale: NoneFloat = 1
"""the number of times this recipe has been added""" """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 id: UUID4
shopping_list_item_id: UUID4 shopping_list_item_id: UUID4
class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRefUpdate):
class Config: class Config:
orm_mode = True orm_mode = True
class ShoppingListItemCreate(MealieModel): class ShoppingListItemBase(MealieModel):
shopping_list_id: UUID4 shopping_list_id: UUID4
checked: bool = False checked: bool = False
position: int = 0 position: int = 0
@ -38,26 +53,110 @@ class ShoppingListItemCreate(MealieModel):
note: str | None = "" note: str | None = ""
quantity: float = 1 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 label_id: UUID4 | None = None
recipe_references: list[ShoppingListItemRecipeRef] = [] unit_id: UUID4 | None = None
extras: dict | 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 created_at: datetime | None
update_at: datetime | None update_at: datetime | None
def __init__(self, **kwargs):
super().__init__(**kwargs)
class ShoppingListItemUpdate(ShoppingListItemCreate): # if we're missing a label, but the food has a label, use that as the label
id: UUID4 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): def _format_quantity_for_display(self) -> str:
label: MultiPurposeLabelSummary | None """How the quantity should be displayed"""
recipe_references: list[ShoppingListItemRecipeRef | ShoppingListItemRecipeRefOut] = []
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: class Config:
orm_mode = True 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): class ShoppingListCreate(MealieModel):
name: str | None = None name: str | None = None
extras: dict | None = {} extras: dict | None = {}

View File

@ -12,6 +12,9 @@ from mealie.schema._mealie import MealieModel
from mealie.schema._mealie.types import NoneFloat from mealie.schema._mealie.types import NoneFloat
from mealie.schema.response.pagination import PaginationBase from mealie.schema.response.pagination import PaginationBase
INGREDIENT_QTY_PRECISION = 3
MAX_INGREDIENT_DENOMINATOR = 32
class UnitFoodBase(MealieModel): class UnitFoodBase(MealieModel):
name: str name: str
@ -97,7 +100,7 @@ class RecipeIngredient(MealieModel):
empty string. empty string.
""" """
if isinstance(value, float): if isinstance(value, float):
return round(value, 3) return round(value, INGREDIENT_QTY_PRECISION)
if value is None or value == "": if value is None or value == "":
return None return None
return value return value
@ -115,7 +118,7 @@ class IngredientConfidence(MealieModel):
@classmethod @classmethod
def validate_quantity(cls, value, values) -> NoneFloat: def validate_quantity(cls, value, values) -> NoneFloat:
if isinstance(value, float): if isinstance(value, float):
return round(value, 3) return round(value, INGREDIENT_QTY_PRECISION)
if value is None or value == "": if value is None or value == "":
return None return None
return value return value

View File

@ -6,12 +6,16 @@ from mealie.core.exceptions import UnexpectedNone
from mealie.repos.repository_factory import AllRepositories from mealie.repos.repository_factory import AllRepositories
from mealie.schema.group import ShoppingListItemCreate, ShoppingListOut from mealie.schema.group import ShoppingListItemCreate, ShoppingListOut
from mealie.schema.group.group_shopping_list import ( from mealie.schema.group.group_shopping_list import (
ShoppingListItemBase,
ShoppingListItemOut, ShoppingListItemOut,
ShoppingListItemRecipeRef, ShoppingListItemRecipeRefCreate,
ShoppingListItemRecipeRefOut, ShoppingListItemRecipeRefOut,
ShoppingListItemsCollectionOut,
ShoppingListItemUpdate, 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: class ShoppingListService:
@ -23,240 +27,359 @@ class ShoppingListService:
self.list_refs = repos.group_shopping_list_recipe_refs self.list_refs = repos.group_shopping_list_recipe_refs
@staticmethod @staticmethod
def can_merge(item1: ShoppingListItemOut, item2: ShoppingListItemOut) -> bool: def can_merge(item1: ShoppingListItemBase, item2: ShoppingListItemBase) -> bool:
""" """Check to see if this item can be merged with another item"""
can_merge checks if the two items can be merged together.
"""
# Check if items are both checked or both unchecked if any(
if item1.checked != item2.checked: [
item1.checked,
item2.checked,
item1.food_id != item2.food_id,
item1.unit_id != item2.unit_id,
]
):
return False return False
# Check if foods are equal # if foods match, we can merge, otherwise compare the notes
foods_is_none = item1.food_id is None and item2.food_id is None return bool(item1.food_id) or item1.note == item2.note
foods_not_none = not foods_is_none
foods_equal = item1.food_id == item2.food_id
# Check if units are equal @staticmethod
units_is_none = item1.unit_id is None and item2.unit_id is None def merge_items(
units_not_none = not units_is_none from_item: ShoppingListItemCreate | ShoppingListItemUpdateBulk,
units_equal = item1.unit_id == item2.unit_id to_item: ShoppingListItemCreate | ShoppingListItemUpdateBulk | ShoppingListItemOut,
) -> ShoppingListItemUpdate:
# 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]:
""" """
iterates through the shopping list provided and returns Takes an item and merges it into an already-existing item, then returns a copy
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. Attributes of the `to_item` take priority over the `from_item`, except extras with overlapping keys
""" """
consolidated_list: list[ShoppingListItemOut] = [] to_item.quantity += from_item.quantity
checked_items: list[int] = [] 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 to_item.extras and from_item.extras:
if base_index in checked_items: 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 continue
checked_items.append(base_index) # merge recipe scales
for inner_index, inner_item in enumerate(item_list): base_ref = updated_refs[to_ref.recipe_id]
if inner_index in checked_items:
# 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 continue
if ShoppingListService.can_merge(base_item, inner_item): filtered_item = self.merge_items(create_item, filtered_item).cast(ShoppingListItemCreate)
# Set Quantity merged = True
base_item.quantity += inner_item.quantity break
# Set References if not merged:
refs = {ref.recipe_id: ref for ref in base_item.recipe_references} consolidated_create_items.append(create_item)
for inner_ref in inner_item.recipe_references:
if inner_ref.recipe_id not in refs:
refs[inner_ref.recipe_id] = inner_ref
else: create_items = consolidated_create_items
# merge recipe scales filtered_create_items: list[ShoppingListItemCreate] = []
base_ref = refs[inner_ref.recipe_id]
# if the scale is missing we assume it's 1 for backwards compatibility # check to see if we can merge into any existing items
# if the scale is 0 we leave it alone update_items: list[ShoppingListItemUpdateBulk] = []
if base_ref.recipe_scale is None: existing_items_map: dict[UUID4, list[ShoppingListItemOut]] = {}
base_ref.recipe_scale = 1 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: merged = False
inner_ref.recipe_scale = 1 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()) if merged or create_item.quantity < 0:
checked_items.append(inner_index) 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( created_items = cast(
self, data: list[ShoppingListItemUpdate] list[ShoppingListItemOut],
) -> tuple[list[ShoppingListItemOut], list[ShoppingListItemOut]]: self.list_items.create_many(filtered_create_items) if filtered_create_items else [], # type: ignore
""" )
returns:
- updated_shopping_list_items
- deleted_shopping_list_items
"""
# TODO: Convert to update many with single call
all_updates = [] updated_items = cast(
all_deletes = [] list[ShoppingListItemOut],
keep_ids = [] self.list_items.update_many(update_items) if update_items else [], # type: ignore
)
for item in self.consolidate_list_items(data): # type: ignore for list_id in set(item.shopping_list_id for item in created_items + updated_items):
updated_data = self.list_items.update(item.id, item) self.remove_unused_recipe_references(list_id)
all_updates.append(updated_data)
keep_ids.append(updated_data.id)
for item in data: # type: ignore return ShoppingListItemsCollectionOut(
if item.id not in keep_ids: created_items=created_items, updated_items=updated_items, deleted_items=[]
self.list_items.delete(item.id) )
all_deletes.append(item)
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
# ======================================================================= seen_update_ids.add(update_item.id)
# Methods
def add_recipe_ingredients_to_list( merged = False
self, list_id: UUID4, recipe_id: UUID4, recipe_increment: float = 1 for filtered_item in consolidated_update_items:
) -> tuple[ShoppingListOut, list[ShoppingListItemOut], list[ShoppingListItemOut], list[ShoppingListItemOut]]: if not self.can_merge(update_item, filtered_item):
""" continue
returns:
- updated_shopping_list filtered_item = self.merge_items(update_item, filtered_item).cast(
- new_shopping_list_items ShoppingListItemUpdateBulk, id=filtered_item.id
- updated_shopping_list_items )
- deleted_shopping_list_items delete_items.add(update_item.id)
""" merged = True
recipe: Recipe | None = self.repos.recipes.get_one(recipe_id, "id") 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: if not recipe:
raise UnexpectedNone("Recipe not found") raise UnexpectedNone("Recipe not found")
to_create = [] list_items: list[ShoppingListItemCreate] = []
for ingredient in recipe.recipe_ingredient: for ingredient in recipe.recipe_ingredient:
food_id = None if isinstance(ingredient.food, IngredientFood):
try: is_food = True
food_id = ingredient.food.id # type: ignore food_id = ingredient.food.id
except AttributeError: label_id = ingredient.food.label_id
pass
label_id = None else:
try: is_food = False
label_id = ingredient.food.label.id # type: ignore food_id = None
except AttributeError: label_id = None
pass
unit_id = None if isinstance(ingredient.unit, IngredientUnit):
try: unit_id = ingredient.unit.id
unit_id = ingredient.unit.id # type: ignore
except AttributeError:
pass
to_create.append( else:
ShoppingListItemCreate( unit_id = None
shopping_list_id=list_id,
is_food=not recipe.settings.disable_amount if recipe.settings else False, new_item = ShoppingListItemCreate(
food_id=food_id, shopping_list_id=list_id,
unit_id=unit_id, is_food=is_food,
quantity=ingredient.quantity * recipe_increment if ingredient.quantity else 0, note=ingredient.note,
note=ingredient.note, quantity=ingredient.quantity * scale if ingredient.quantity else 0,
label_id=label_id, food_id=food_id,
recipe_id=recipe_id, label_id=label_id,
recipe_references=[ unit_id=unit_id,
ShoppingListItemRecipeRef( recipe_references=[
recipe_id=recipe_id, recipe_quantity=ingredient.quantity, recipe_scale=recipe_increment 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) # since this is the same recipe, we combine the quanities, rather than the scales
if not updated_shopping_list: # all items will have exactly one recipe reference
raise UnexpectedNone("Shopping List not found") 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( # merge notes
updated_shopping_list.list_items, # type: ignore if existing_item.note != new_item.note:
) existing_item.note = " | ".join([note for note in [existing_item.note, new_item.note] if note])
updated_shopping_list.list_items = updated_shopping_list_items
not_found = True merged = True
for refs in updated_shopping_list.recipe_references: break
if refs.recipe_id != recipe_id:
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 continue
refs.recipe_quantity += recipe_increment ref.recipe_quantity += recipe_increment
not_found = False ref_merged = True
break break
if not_found: if not ref_merged:
updated_shopping_list.recipe_references.append( updated_list.recipe_references.append(
ShoppingListItemRecipeRef(recipe_id=recipe_id, recipe_quantity=recipe_increment) # type: ignore 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) updated_list = self.shopping_lists.update(updated_list.id, updated_list)
return updated_list, item_changes
"""
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
def remove_recipe_ingredients_from_list( def remove_recipe_ingredients_from_list(
self, list_id: UUID4, recipe_id: UUID4, recipe_decrement: float = 1 self, list_id: UUID4, recipe_id: UUID4, recipe_decrement: float = 1
) -> tuple[ShoppingListOut, list[ShoppingListItemOut], list[ShoppingListItemOut]]: ) -> tuple[ShoppingListOut, ShoppingListItemsCollectionOut]:
""" """
returns: Removes a recipe's ingredients from a list
- updated_shopping_list
- updated_shopping_list_items Returns a tuple of:
- deleted_shopping_list_items - Updated Shopping List
- Impacted Shopping List Items
""" """
shopping_list = self.shopping_lists.get_one(list_id) shopping_list = self.shopping_lists.get_one(list_id)
if shopping_list is None: if shopping_list is None:
raise UnexpectedNone("Shopping list not found, cannot remove recipe ingredients") raise UnexpectedNone("Shopping list not found, cannot remove recipe ingredients")
updated_shopping_list_items = [] update_items: list[ShoppingListItemUpdateBulk] = []
deleted_shopping_list_items = [] delete_items: list[UUID4] = []
for item in shopping_list.list_items: for item in shopping_list.list_items:
found = False found = False
@ -270,10 +393,6 @@ class ShoppingListService:
if ref.recipe_scale is None: if ref.recipe_scale is None:
ref.recipe_scale = 1 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 # Set Quantity
if ref.recipe_scale > recipe_decrement: if ref.recipe_scale > recipe_decrement:
# remove only part of the reference # remove only part of the reference
@ -286,25 +405,33 @@ class ShoppingListService:
# Set Reference Scale # Set Reference Scale
ref.recipe_scale -= recipe_decrement ref.recipe_scale -= recipe_decrement
if ref.recipe_scale <= 0: 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) item.recipe_references.remove(ref)
found = True found = True
break break
# If the item was found we need to check its new quantity
if found: if found:
if item.quantity <= 0: # only remove a 0 quantity item if we removed its last recipe reference
self.list_items.delete(item.id) if item.quantity < 0 or (item.quantity == 0 and not item.recipe_references):
deleted_shopping_list_items.append(item) delete_items.append(item.id)
else: else:
self.list_items.update(item.id, item) update_items.append(item.cast(ShoppingListItemUpdateBulk))
updated_shopping_list_items.append(item)
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 # 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: if recipe_ref.recipe_id != recipe_id or recipe_ref.recipe_quantity is None:
continue continue
@ -318,8 +445,4 @@ class ShoppingListService:
break break
return ( return self.shopping_lists.get_one(shopping_list.id), items # type: ignore
self.shopping_lists.get_one(shopping_list.id),
updated_shopping_list_items,
deleted_shopping_list_items,
) # type: ignore

View File

@ -4,6 +4,7 @@ from fractions import Fraction
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
from mealie.schema.recipe import RecipeIngredient from mealie.schema.recipe import RecipeIngredient
from mealie.schema.recipe.recipe_ingredient import ( from mealie.schema.recipe.recipe_ingredient import (
MAX_INGREDIENT_DENOMINATOR,
CreateIngredientFood, CreateIngredientFood,
CreateIngredientUnit, CreateIngredientUnit,
IngredientConfidence, IngredientConfidence,
@ -74,7 +75,9 @@ class NLPParser(ABCIngredientParser):
unit=CreateIngredientUnit(name=crf_model.unit), unit=CreateIngredientUnit(name=crf_model.unit),
food=CreateIngredientFood(name=crf_model.name), food=CreateIngredientFood(name=crf_model.name),
disable_amount=False, 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: except Exception as e:
logger.error(f"Failed to parse ingredient: {crf_model}: {e}") logger.error(f"Failed to parse ingredient: {crf_model}: {e}")

View File

@ -17,9 +17,7 @@ def create_item(list_id: UUID4) -> dict:
"note": random_string(10), "note": random_string(10),
"quantity": 1, "quantity": 1,
"unit_id": None, "unit_id": None,
"unit": None,
"food_id": None, "food_id": None,
"food": None,
"recipe_id": None, "recipe_id": None,
"label_id": None, "label_id": None,
} }
@ -75,7 +73,7 @@ def list_with_items(database: AllRepositories, unique_user: TestUser):
) )
# refresh model # 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 yield list_model

View File

@ -1,31 +1,22 @@
import random import random
from math import ceil, floor
from uuid import uuid4 from uuid import uuid4
import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from pydantic import UUID4 from pydantic import UUID4
from mealie.schema.group.group_shopping_list import ShoppingListItemOut, ShoppingListOut from mealie.schema.group.group_shopping_list import ShoppingListItemOut, ShoppingListOut
from tests import utils from tests import utils
from tests.utils import api_routes 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 from tests.utils.fixture_schemas import TestUser
def create_item(list_id: UUID4) -> dict: def create_item(list_id: UUID4) -> dict:
return { return {
"shopping_list_id": str(list_id), "shopping_list_id": str(list_id),
"checked": False,
"position": 0,
"is_food": False,
"note": random_string(10), "note": random_string(10),
"quantity": 1, "quantity": random_int(1, 10),
"unit_id": None,
"unit": None,
"food_id": None,
"food": None,
"recipe_id": None,
"label_id": None,
} }
@ -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) response = api_client.post(api_routes.groups_shopping_items, json=item, headers=unique_user.token)
as_json = utils.assert_derserialize(response, 201) as_json = utils.assert_derserialize(response, 201)
assert len(as_json["createdItems"]) == 1
# Test Item is Getable # 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) 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) as_json = utils.assert_derserialize(response, 200)
@ -64,10 +56,39 @@ def test_shopping_list_items_create_one(
assert len(response_list["listItems"]) == 1 assert len(response_list["listItems"]) == 1
# Check Item Id's # Check Item Ids
assert response_list["listItems"][0]["id"] == created_item_id 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( def test_shopping_list_items_get_one(
api_client: TestClient, api_client: TestClient,
unique_user: TestUser, 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 api_routes.groups_shopping_items_item_id(item.id), json=update_data, headers=unique_user.token
) )
item_json = utils.assert_derserialize(response, 200) 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( def test_shopping_list_items_delete_one(
@ -122,44 +218,6 @@ def test_shopping_list_items_delete_one(
assert response.status_code == 404 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( def test_shopping_list_items_update_many_consolidates_common_items(
api_client: TestClient, api_client: TestClient,
unique_user: TestUser, 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 assert response_list["listItems"][0]["note"] == master_note
@pytest.mark.skip("TODO: Implement") def test_shopping_list_items_add_mergeable(
def test_shopping_list_items_update_many_remove_recipe_with_other_items( api_client: TestClient, unique_user: TestUser, shopping_list: ShoppingListOut
api_client: TestClient, ):
unique_user: TestUser, # add a bunch of items that can be consolidated
list_with_items: ShoppingListOut, items = [create_item(shopping_list.id) for _ in range(5)]
) -> None:
# list_items = list_with_items.list_items common_note = random_string()
pass 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( 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} 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) 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 # make sure the extra persists
extras = item_as_json["extras"] extras = item_as_json["extras"]
@ -226,7 +521,8 @@ def test_shopping_list_item_extras(
response = api_client.put( response = api_client.put(
api_routes.groups_shopping_items_item_id(item_as_json["id"]), json=item_as_json, headers=unique_user.token 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 # make sure both the new extra and original extra persist
extras = item_as_json["extras"] extras = item_as_json["extras"]

View File

@ -91,7 +91,6 @@ def test_shopping_lists_add_recipe(
recipe_ingredient_only: Recipe, recipe_ingredient_only: Recipe,
): ):
sample_list = random.choice(shopping_lists) sample_list = random.choice(shopping_lists)
recipe = recipe_ingredient_only recipe = recipe_ingredient_only
response = api_client.post( response = api_client.post(
@ -99,24 +98,185 @@ def test_shopping_lists_add_recipe(
) )
assert response.status_code == 200 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) 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) as_json = utils.assert_derserialize(response, 200)
assert len(as_json["listItems"]) == len(recipe.recipe_ingredient) 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"]: for item in as_json["listItems"]:
assert item["note"] in known_ingredients assert item["note"] in known_ingredients
# Check Recipe Reference was added with quantity 1 ingredient = known_ingredients[item["note"]]
refs = item["recipeReferences"] assert item["quantity"] == (ingredient.quantity or 0) * 2
refs = as_json["recipeReferences"]
assert len(refs) == 1 assert len(refs) == 1
assert refs[0]["recipeId"] == str(recipe.id) 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( def test_shopping_list_add_recipe_scale(
@ -182,32 +342,49 @@ def test_shopping_lists_remove_recipe(
recipe_ingredient_only: Recipe, recipe_ingredient_only: Recipe,
): ):
sample_list = random.choice(shopping_lists) sample_list = random.choice(shopping_lists)
recipe = recipe_ingredient_only recipe = recipe_ingredient_only
# add two instances of the recipe
payload = {"recipeIncrementQuantity": 2}
response = api_client.post( 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 assert response.status_code == 200
# Get List and Check for Ingredients # remove one instance of the recipe
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
response = api_client.post( response = api_client.post(
api_routes.groups_shopping_lists_item_id_recipe_recipe_id_delete(sample_list.id, recipe.id), api_routes.groups_shopping_lists_item_id_recipe_recipe_id_delete(sample_list.id, recipe.id),
headers=unique_user.token, 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) 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) as_json = utils.assert_derserialize(response, 200)
assert len(as_json["listItems"]) == 0 assert len(as_json["listItems"]) == 0
@ -221,7 +398,6 @@ def test_shopping_lists_remove_recipe_multiple_quantity(
recipe_ingredient_only: Recipe, recipe_ingredient_only: Recipe,
): ):
sample_list = random.choice(shopping_lists) sample_list = random.choice(shopping_lists)
recipe = recipe_ingredient_only recipe = recipe_ingredient_only
for _ in range(3): for _ in range(3):
@ -357,13 +533,14 @@ def test_recipe_decrement_max(
# next add a little bit more of one item # next add a little bit more of one item
item_additional_quantity = random_int(1, 10) 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 item_json["quantity"] += item_additional_quantity
response = api_client.put( 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 assert item_json["quantity"] == recipe_scale + item_additional_quantity
# now remove way too many instances of the recipe # now remove way too many instances of the recipe
@ -386,6 +563,105 @@ def test_recipe_decrement_max(
assert len(item["recipeReferences"]) == 0 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( def test_shopping_list_extras(
api_client: TestClient, api_client: TestClient,
unique_user: TestUser, unique_user: TestUser,

View File

@ -93,6 +93,8 @@ groups_self = "/api/groups/self"
"""`/api/groups/self`""" """`/api/groups/self`"""
groups_shopping_items = "/api/groups/shopping/items" groups_shopping_items = "/api/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" groups_shopping_lists = "/api/groups/shopping/lists"
"""`/api/groups/shopping/lists`""" """`/api/groups/shopping/lists`"""
groups_statistics = "/api/groups/statistics" groups_statistics = "/api/groups/statistics"