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