feat: Improved Recipe Scaling Support and Shopping List Recipe Refactor (#1847)
* propogate scale changes to print view * fixed incorrect variable reference * refactored shopping list recipe routes cleaned up existing logic added support for recipe scaling * updated current revision * adding to shopping list respects UI recipe scale * added field annotations * added tests for recipe scaling * made column nullable and set to 1 during migration
This commit is contained in:
parent
d9c39cc1d0
commit
46cc3898ab
13 changed files with 344 additions and 49 deletions
|
@ -0,0 +1,29 @@
|
||||||
|
"""add recipe_scale to shopping list item ref
|
||||||
|
|
||||||
|
Revision ID: 167eb69066ad
|
||||||
|
Revises: 1923519381ad
|
||||||
|
Create Date: 2022-11-22 03:42:45.494567
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "167eb69066ad"
|
||||||
|
down_revision = "1923519381ad"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column("shopping_list_item_recipe_reference", sa.Column("recipe_scale", sa.Float(), nullable=True))
|
||||||
|
op.execute("UPDATE shopping_list_item_recipe_reference SET recipe_scale = 1")
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column("shopping_list_item_recipe_reference", "recipe_scale")
|
||||||
|
# ### end Alembic commands ###
|
|
@ -51,6 +51,7 @@
|
||||||
color="info"
|
color="info"
|
||||||
:card-menu="false"
|
:card-menu="false"
|
||||||
:recipe-id="recipe.id"
|
:recipe-id="recipe.id"
|
||||||
|
:recipe-scale="recipeScale"
|
||||||
:use-items="{
|
:use-items="{
|
||||||
delete: false,
|
delete: false,
|
||||||
edit: false,
|
edit: false,
|
||||||
|
@ -105,6 +106,10 @@ export default defineComponent({
|
||||||
required: true,
|
required: true,
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
|
recipeScale: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
open: {
|
open: {
|
||||||
required: true,
|
required: true,
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
|
|
@ -199,6 +199,10 @@ export default defineComponent({
|
||||||
required: true,
|
required: true,
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
|
recipeScale: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Optional group ID prop that is only _required_ when the
|
* Optional group ID prop that is only _required_ when the
|
||||||
* public URL is requested. If the public URL button is pressed
|
* public URL is requested. If the public URL button is pressed
|
||||||
|
@ -316,7 +320,7 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addRecipeToList(listId: string) {
|
async function addRecipeToList(listId: string) {
|
||||||
const { data } = await api.shopping.lists.addRecipe(listId, props.recipeId);
|
const { data } = await api.shopping.lists.addRecipe(listId, props.recipeId, props.recipeScale);
|
||||||
if (data) {
|
if (data) {
|
||||||
alert.success(i18n.t("recipe.recipe-added-to-list") as string);
|
alert.success(i18n.t("recipe.recipe-added-to-list") as string);
|
||||||
state.shoppingListDialog = false;
|
state.shoppingListDialog = false;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<v-container :class="{ 'pa-0': $vuetify.breakpoint.smAndDown }">
|
<v-container :class="{ 'pa-0': $vuetify.breakpoint.smAndDown }">
|
||||||
<v-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
|
<v-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
|
||||||
<RecipePageHeader :recipe="recipe" :landscape="landscape" @save="saveRecipe" @delete="deleteRecipe" />
|
<RecipePageHeader :recipe="recipe" :recipe-scale="scale" :landscape="landscape" @save="saveRecipe" @delete="deleteRecipe" />
|
||||||
<LazyRecipeJsonEditor v-if="isEditJSON" v-model="recipe" class="mt-10" :options="EDITOR_OPTIONS" />
|
<LazyRecipeJsonEditor v-if="isEditJSON" v-model="recipe" class="mt-10" :options="EDITOR_OPTIONS" />
|
||||||
<v-card-text v-else>
|
<v-card-text v-else>
|
||||||
<!--
|
<!--
|
||||||
|
@ -70,7 +70,7 @@
|
||||||
:recipe="recipe"
|
:recipe="recipe"
|
||||||
class="px-1 my-4 d-print-none"
|
class="px-1 my-4 d-print-none"
|
||||||
/>
|
/>
|
||||||
<RecipePrintView :recipe="recipe" />
|
<RecipePrintView :recipe="recipe" :scale="scale" />
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -45,6 +45,7 @@
|
||||||
v-if="user.id"
|
v-if="user.id"
|
||||||
:recipe="recipe"
|
:recipe="recipe"
|
||||||
:slug="recipe.slug"
|
:slug="recipe.slug"
|
||||||
|
:recipe-scale="recipeScale"
|
||||||
:locked="user.id !== recipe.userId && recipe.settings.locked"
|
:locked="user.id !== recipe.userId && recipe.settings.locked"
|
||||||
:name="recipe.name"
|
:name="recipe.name"
|
||||||
:logged-in="$auth.loggedIn"
|
:logged-in="$auth.loggedIn"
|
||||||
|
@ -85,6 +86,10 @@ export default defineComponent({
|
||||||
type: Object as () => NoUndefinedField<Recipe>,
|
type: Object as () => NoUndefinedField<Recipe>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
recipeScale: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
landscape: {
|
landscape: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
|
|
|
@ -96,6 +96,10 @@ export default defineComponent({
|
||||||
type: Object as () => Recipe,
|
type: Object as () => Recipe,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
scale: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
// Group ingredients by section so we can style them independently
|
// Group ingredients by section so we can style them independently
|
||||||
|
@ -181,7 +185,7 @@ export default defineComponent({
|
||||||
});
|
});
|
||||||
|
|
||||||
function parseText(ingredient: RecipeIngredient) {
|
function parseText(ingredient: RecipeIngredient) {
|
||||||
return parseIngredientText(ingredient, props.recipe.settings?.disableAmount || false);
|
return parseIngredientText(ingredient, props.recipe.settings?.disableAmount || false, props.scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -24,8 +24,8 @@ export class ShoppingListsApi extends BaseCRUDAPI<ShoppingListCreate, ShoppingLi
|
||||||
baseRoute = routes.shoppingLists;
|
baseRoute = routes.shoppingLists;
|
||||||
itemRoute = routes.shoppingListsId;
|
itemRoute = routes.shoppingListsId;
|
||||||
|
|
||||||
async addRecipe(itemId: string, recipeId: string) {
|
async addRecipe(itemId: string, recipeId: string, recipeIncrementQuantity = 1) {
|
||||||
return await this.requests.post(routes.shoppingListIdAddRecipe(itemId, recipeId), {});
|
return await this.requests.post(routes.shoppingListIdAddRecipe(itemId, recipeId), {recipeIncrementQuantity});
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeRecipe(itemId: string, recipeId: string) {
|
async removeRecipe(itemId: string, recipeId: string) {
|
||||||
|
|
|
@ -17,6 +17,7 @@ class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase):
|
||||||
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True)
|
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True)
|
||||||
recipe = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs")
|
recipe = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs")
|
||||||
recipe_quantity = Column(Float, nullable=False)
|
recipe_quantity = Column(Float, nullable=False)
|
||||||
|
recipe_scale = Column(Float, nullable=False, default=1)
|
||||||
|
|
||||||
@auto_init()
|
@auto_init()
|
||||||
def __init__(self, **_) -> None:
|
def __init__(self, **_) -> None:
|
||||||
|
|
|
@ -7,12 +7,14 @@ from mealie.routes._base.base_controllers import BaseCrudController
|
||||||
from mealie.routes._base.controller import controller
|
from mealie.routes._base.controller import controller
|
||||||
from mealie.routes._base.mixins import HttpRepo
|
from mealie.routes._base.mixins import HttpRepo
|
||||||
from mealie.schema.group.group_shopping_list import (
|
from mealie.schema.group.group_shopping_list import (
|
||||||
|
ShoppingListAddRecipeParams,
|
||||||
ShoppingListCreate,
|
ShoppingListCreate,
|
||||||
ShoppingListItemCreate,
|
ShoppingListItemCreate,
|
||||||
ShoppingListItemOut,
|
ShoppingListItemOut,
|
||||||
ShoppingListItemUpdate,
|
ShoppingListItemUpdate,
|
||||||
ShoppingListOut,
|
ShoppingListOut,
|
||||||
ShoppingListPagination,
|
ShoppingListPagination,
|
||||||
|
ShoppingListRemoveRecipeParams,
|
||||||
ShoppingListSave,
|
ShoppingListSave,
|
||||||
ShoppingListSummary,
|
ShoppingListSummary,
|
||||||
ShoppingListUpdate,
|
ShoppingListUpdate,
|
||||||
|
@ -218,13 +220,17 @@ class ShoppingListController(BaseCrudController):
|
||||||
# Other Operations
|
# Other Operations
|
||||||
|
|
||||||
@router.post("/{item_id}/recipe/{recipe_id}", response_model=ShoppingListOut)
|
@router.post("/{item_id}/recipe/{recipe_id}", response_model=ShoppingListOut)
|
||||||
def add_recipe_ingredients_to_list(self, item_id: UUID4, recipe_id: UUID4):
|
def add_recipe_ingredients_to_list(
|
||||||
|
self, item_id: UUID4, recipe_id: UUID4, data: ShoppingListAddRecipeParams | None = None
|
||||||
|
):
|
||||||
(
|
(
|
||||||
shopping_list,
|
shopping_list,
|
||||||
new_shopping_list_items,
|
new_shopping_list_items,
|
||||||
updated_shopping_list_items,
|
updated_shopping_list_items,
|
||||||
deleted_shopping_list_items,
|
deleted_shopping_list_items,
|
||||||
) = self.service.add_recipe_ingredients_to_list(item_id, recipe_id)
|
) = self.service.add_recipe_ingredients_to_list(
|
||||||
|
item_id, recipe_id, data.recipe_increment_quantity if data else 1
|
||||||
|
)
|
||||||
|
|
||||||
if new_shopping_list_items:
|
if new_shopping_list_items:
|
||||||
self.publish_event(
|
self.publish_event(
|
||||||
|
@ -263,12 +269,16 @@ class ShoppingListController(BaseCrudController):
|
||||||
return shopping_list
|
return shopping_list
|
||||||
|
|
||||||
@router.delete("/{item_id}/recipe/{recipe_id}", response_model=ShoppingListOut)
|
@router.delete("/{item_id}/recipe/{recipe_id}", response_model=ShoppingListOut)
|
||||||
def remove_recipe_ingredients_from_list(self, item_id: UUID4, recipe_id: UUID4):
|
def remove_recipe_ingredients_from_list(
|
||||||
|
self, item_id: UUID4, recipe_id: UUID4, data: ShoppingListRemoveRecipeParams | None = None
|
||||||
|
):
|
||||||
(
|
(
|
||||||
shopping_list,
|
shopping_list,
|
||||||
updated_shopping_list_items,
|
updated_shopping_list_items,
|
||||||
deleted_shopping_list_items,
|
deleted_shopping_list_items,
|
||||||
) = self.service.remove_recipe_ingredients_from_list(item_id, recipe_id)
|
) = self.service.remove_recipe_ingredients_from_list(
|
||||||
|
item_id, recipe_id, data.recipe_decrement_quantity if data else 1
|
||||||
|
)
|
||||||
|
|
||||||
if updated_shopping_list_items:
|
if updated_shopping_list_items:
|
||||||
self.publish_event(
|
self.publish_event(
|
||||||
|
|
|
@ -15,6 +15,10 @@ from mealie.schema.response.pagination import PaginationBase
|
||||||
class ShoppingListItemRecipeRef(MealieModel):
|
class ShoppingListItemRecipeRef(MealieModel):
|
||||||
recipe_id: UUID4
|
recipe_id: UUID4
|
||||||
recipe_quantity: NoneFloat = 0
|
recipe_quantity: NoneFloat = 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"""
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRef):
|
class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRef):
|
||||||
|
@ -79,6 +83,8 @@ class ShoppingListRecipeRefOut(MealieModel):
|
||||||
shopping_list_id: UUID4
|
shopping_list_id: UUID4
|
||||||
recipe_id: UUID4
|
recipe_id: UUID4
|
||||||
recipe_quantity: float
|
recipe_quantity: float
|
||||||
|
"""the number of times this recipe has been added"""
|
||||||
|
|
||||||
recipe: RecipeSummary
|
recipe: RecipeSummary
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
@ -118,6 +124,14 @@ class ShoppingListOut(ShoppingListUpdate):
|
||||||
orm_mode = True
|
orm_mode = True
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingListAddRecipeParams(MealieModel):
|
||||||
|
recipe_increment_quantity: float = 1
|
||||||
|
|
||||||
|
|
||||||
|
class ShoppingListRemoveRecipeParams(MealieModel):
|
||||||
|
recipe_decrement_quantity: float = 1
|
||||||
|
|
||||||
|
|
||||||
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary # noqa: E402
|
from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary # noqa: E402
|
||||||
from mealie.schema.recipe.recipe import RecipeSummary # noqa: E402
|
from mealie.schema.recipe.recipe import RecipeSummary # noqa: E402
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from pydantic import UUID4
|
from pydantic import UUID4
|
||||||
|
|
||||||
from mealie.core.exceptions import UnexpectedNone
|
from mealie.core.exceptions import UnexpectedNone
|
||||||
|
@ -6,8 +8,10 @@ from mealie.schema.group import ShoppingListItemCreate, ShoppingListOut
|
||||||
from mealie.schema.group.group_shopping_list import (
|
from mealie.schema.group.group_shopping_list import (
|
||||||
ShoppingListItemOut,
|
ShoppingListItemOut,
|
||||||
ShoppingListItemRecipeRef,
|
ShoppingListItemRecipeRef,
|
||||||
|
ShoppingListItemRecipeRefOut,
|
||||||
ShoppingListItemUpdate,
|
ShoppingListItemUpdate,
|
||||||
)
|
)
|
||||||
|
from mealie.schema.recipe import Recipe
|
||||||
|
|
||||||
|
|
||||||
class ShoppingListService:
|
class ShoppingListService:
|
||||||
|
@ -34,7 +38,7 @@ class ShoppingListService:
|
||||||
units_not_none = not units_is_none
|
units_not_none = not units_is_none
|
||||||
units_equal = item1.unit_id == item2.unit_id
|
units_equal = item1.unit_id == item2.unit_id
|
||||||
|
|
||||||
# Check if Notes are equal
|
# Check if notes are equal
|
||||||
if foods_is_none and units_is_none:
|
if foods_is_none and units_is_none:
|
||||||
return item1.note == item2.note
|
return item1.note == item2.note
|
||||||
|
|
||||||
|
@ -48,7 +52,7 @@ class ShoppingListService:
|
||||||
|
|
||||||
def consolidate_list_items(self, item_list: list[ShoppingListItemOut]) -> list[ShoppingListItemOut]:
|
def consolidate_list_items(self, item_list: list[ShoppingListItemOut]) -> list[ShoppingListItemOut]:
|
||||||
"""
|
"""
|
||||||
itterates through the shopping list provided and returns
|
iterates through the shopping list provided and returns
|
||||||
a consolidated list where all items that are matched against multiple values are
|
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.
|
de-duplicated and only the first item is kept where the quantity is updated accordingly.
|
||||||
"""
|
"""
|
||||||
|
@ -64,17 +68,32 @@ class ShoppingListService:
|
||||||
for inner_index, inner_item in enumerate(item_list):
|
for inner_index, inner_item in enumerate(item_list):
|
||||||
if inner_index in checked_items:
|
if inner_index in checked_items:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if ShoppingListService.can_merge(base_item, inner_item):
|
if ShoppingListService.can_merge(base_item, inner_item):
|
||||||
# Set Quantity
|
# Set Quantity
|
||||||
base_item.quantity += inner_item.quantity
|
base_item.quantity += inner_item.quantity
|
||||||
|
|
||||||
# Set References
|
# Set References
|
||||||
new_refs = []
|
refs = {ref.recipe_id: ref for ref in base_item.recipe_references}
|
||||||
for ref in inner_item.recipe_references:
|
for inner_ref in inner_item.recipe_references:
|
||||||
ref.shopping_list_item_id = base_item.id # type: ignore
|
if inner_ref.recipe_id not in refs:
|
||||||
new_refs.append(ref)
|
refs[inner_ref.recipe_id] = inner_ref
|
||||||
|
|
||||||
base_item.recipe_references.extend(new_refs)
|
else:
|
||||||
|
# merge recipe scales
|
||||||
|
base_ref = refs[inner_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 inner_ref.recipe_scale is None:
|
||||||
|
inner_ref.recipe_scale = 1
|
||||||
|
|
||||||
|
base_ref.recipe_scale += inner_ref.recipe_scale
|
||||||
|
|
||||||
|
base_item.recipe_references = list(refs.values())
|
||||||
checked_items.append(inner_index)
|
checked_items.append(inner_index)
|
||||||
|
|
||||||
consolidated_list.append(base_item)
|
consolidated_list.append(base_item)
|
||||||
|
@ -111,7 +130,7 @@ class ShoppingListService:
|
||||||
# Methods
|
# Methods
|
||||||
|
|
||||||
def add_recipe_ingredients_to_list(
|
def add_recipe_ingredients_to_list(
|
||||||
self, list_id: UUID4, recipe_id: UUID4
|
self, list_id: UUID4, recipe_id: UUID4, recipe_increment: float = 1
|
||||||
) -> tuple[ShoppingListOut, list[ShoppingListItemOut], list[ShoppingListItemOut], list[ShoppingListItemOut]]:
|
) -> tuple[ShoppingListOut, list[ShoppingListItemOut], list[ShoppingListItemOut], list[ShoppingListItemOut]]:
|
||||||
"""
|
"""
|
||||||
returns:
|
returns:
|
||||||
|
@ -120,7 +139,7 @@ class ShoppingListService:
|
||||||
- updated_shopping_list_items
|
- updated_shopping_list_items
|
||||||
- deleted_shopping_list_items
|
- deleted_shopping_list_items
|
||||||
"""
|
"""
|
||||||
recipe = self.repos.recipes.get_one(recipe_id, "id")
|
recipe: Recipe | None = self.repos.recipes.get_one(recipe_id, "id")
|
||||||
if not recipe:
|
if not recipe:
|
||||||
raise UnexpectedNone("Recipe not found")
|
raise UnexpectedNone("Recipe not found")
|
||||||
|
|
||||||
|
@ -150,14 +169,13 @@ class ShoppingListService:
|
||||||
is_food=not recipe.settings.disable_amount if recipe.settings else False,
|
is_food=not recipe.settings.disable_amount if recipe.settings else False,
|
||||||
food_id=food_id,
|
food_id=food_id,
|
||||||
unit_id=unit_id,
|
unit_id=unit_id,
|
||||||
quantity=ingredient.quantity,
|
quantity=ingredient.quantity * recipe_increment if ingredient.quantity else 0,
|
||||||
note=ingredient.note,
|
note=ingredient.note,
|
||||||
label_id=label_id,
|
label_id=label_id,
|
||||||
recipe_id=recipe_id,
|
recipe_id=recipe_id,
|
||||||
recipe_references=[
|
recipe_references=[
|
||||||
ShoppingListItemRecipeRef(
|
ShoppingListItemRecipeRef(
|
||||||
recipe_id=recipe_id,
|
recipe_id=recipe_id, recipe_quantity=ingredient.quantity, recipe_scale=recipe_increment
|
||||||
recipe_quantity=ingredient.quantity,
|
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -176,13 +194,16 @@ class ShoppingListService:
|
||||||
|
|
||||||
not_found = True
|
not_found = True
|
||||||
for refs in updated_shopping_list.recipe_references:
|
for refs in updated_shopping_list.recipe_references:
|
||||||
if refs.recipe_id == recipe_id:
|
if refs.recipe_id != recipe_id:
|
||||||
refs.recipe_quantity += 1
|
continue
|
||||||
|
|
||||||
|
refs.recipe_quantity += recipe_increment
|
||||||
not_found = False
|
not_found = False
|
||||||
|
break
|
||||||
|
|
||||||
if not_found:
|
if not_found:
|
||||||
updated_shopping_list.recipe_references.append(
|
updated_shopping_list.recipe_references.append(
|
||||||
ShoppingListItemRecipeRef(recipe_id=recipe_id, recipe_quantity=1) # type: ignore
|
ShoppingListItemRecipeRef(recipe_id=recipe_id, recipe_quantity=recipe_increment) # type: ignore
|
||||||
)
|
)
|
||||||
|
|
||||||
updated_shopping_list = self.shopping_lists.update(updated_shopping_list.id, updated_shopping_list)
|
updated_shopping_list = self.shopping_lists.update(updated_shopping_list.id, updated_shopping_list)
|
||||||
|
@ -217,7 +238,7 @@ class ShoppingListService:
|
||||||
return updated_shopping_list, new_shopping_list_items, updated_shopping_list_items, deleted_shopping_list_items
|
return updated_shopping_list, new_shopping_list_items, updated_shopping_list_items, deleted_shopping_list_items
|
||||||
|
|
||||||
def remove_recipe_ingredients_from_list(
|
def remove_recipe_ingredients_from_list(
|
||||||
self, list_id: UUID4, recipe_id: UUID4
|
self, list_id: UUID4, recipe_id: UUID4, recipe_decrement: float = 1
|
||||||
) -> tuple[ShoppingListOut, list[ShoppingListItemOut], list[ShoppingListItemOut]]:
|
) -> tuple[ShoppingListOut, list[ShoppingListItemOut], list[ShoppingListItemOut]]:
|
||||||
"""
|
"""
|
||||||
returns:
|
returns:
|
||||||
|
@ -225,8 +246,8 @@ class ShoppingListService:
|
||||||
- updated_shopping_list_items
|
- updated_shopping_list_items
|
||||||
- deleted_shopping_list_items
|
- deleted_shopping_list_items
|
||||||
"""
|
"""
|
||||||
shopping_list = self.shopping_lists.get_one(list_id)
|
|
||||||
|
|
||||||
|
shopping_list = self.shopping_lists.get_one(list_id)
|
||||||
if shopping_list is None:
|
if shopping_list is None:
|
||||||
raise UnexpectedNone("Shopping list not found, cannot remove recipe ingredients")
|
raise UnexpectedNone("Shopping list not found, cannot remove recipe ingredients")
|
||||||
|
|
||||||
|
@ -235,39 +256,62 @@ class ShoppingListService:
|
||||||
for item in shopping_list.list_items:
|
for item in shopping_list.list_items:
|
||||||
found = False
|
found = False
|
||||||
|
|
||||||
for ref in item.recipe_references:
|
refs = cast(list[ShoppingListItemRecipeRefOut], item.recipe_references)
|
||||||
remove_qty: None | float = 0.0
|
for ref in refs:
|
||||||
|
if ref.recipe_id != recipe_id:
|
||||||
|
continue
|
||||||
|
|
||||||
if ref.recipe_id == recipe_id:
|
# if the scale is missing we assume it's 1 for backwards compatibility
|
||||||
self.list_item_refs.delete(ref.id) # type: ignore
|
# if the scale is 0 we leave it alone
|
||||||
|
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
|
||||||
|
item.quantity -= recipe_decrement * ref.recipe_quantity
|
||||||
|
|
||||||
|
else:
|
||||||
|
# remove everything that's left on the reference
|
||||||
|
item.quantity -= ref.recipe_scale * ref.recipe_quantity
|
||||||
|
|
||||||
|
# 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)
|
item.recipe_references.remove(ref)
|
||||||
|
|
||||||
found = True
|
found = True
|
||||||
remove_qty = ref.recipe_quantity
|
break
|
||||||
break # only remove one instance of the recipe for each item
|
|
||||||
|
|
||||||
# If the item was found decrement the quantity by the remove_qty
|
# If the item was found we need to check its new quantity
|
||||||
if found:
|
if found:
|
||||||
|
|
||||||
if remove_qty is not None:
|
|
||||||
item.quantity = item.quantity - remove_qty
|
|
||||||
|
|
||||||
if item.quantity <= 0:
|
if item.quantity <= 0:
|
||||||
self.list_items.delete(item.id)
|
self.list_items.delete(item.id)
|
||||||
deleted_shopping_list_items.append(item)
|
deleted_shopping_list_items.append(item)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.list_items.update(item.id, item)
|
self.list_items.update(item.id, item)
|
||||||
updated_shopping_list_items.append(item)
|
updated_shopping_list_items.append(item)
|
||||||
|
|
||||||
# Decrement the list recipe reference count
|
# Decrement the list recipe reference count
|
||||||
for recipe_ref in shopping_list.recipe_references:
|
for recipe_ref in shopping_list.recipe_references:
|
||||||
if recipe_ref.recipe_id == recipe_id and recipe_ref.recipe_quantity is not None:
|
if recipe_ref.recipe_id != recipe_id or recipe_ref.recipe_quantity is None:
|
||||||
recipe_ref.recipe_quantity -= 1.0
|
continue
|
||||||
|
|
||||||
|
recipe_ref.recipe_quantity -= recipe_decrement
|
||||||
|
|
||||||
if recipe_ref.recipe_quantity <= 0.0:
|
if recipe_ref.recipe_quantity <= 0.0:
|
||||||
self.list_refs.delete(recipe_ref.id)
|
self.list_refs.delete(recipe_ref.id)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.list_refs.update(recipe_ref.id, ref)
|
self.list_refs.update(recipe_ref.id, recipe_ref)
|
||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -6,7 +6,7 @@ from mealie.schema.group.group_shopping_list import ShoppingListOut
|
||||||
from mealie.schema.recipe.recipe import Recipe
|
from mealie.schema.recipe.recipe import Recipe
|
||||||
from tests import utils
|
from tests import utils
|
||||||
from tests.utils import api_routes
|
from tests.utils import api_routes
|
||||||
from tests.utils.factories import random_string
|
from tests.utils.factories import random_int, random_string
|
||||||
from tests.utils.fixture_schemas import TestUser
|
from tests.utils.fixture_schemas import TestUser
|
||||||
|
|
||||||
|
|
||||||
|
@ -119,6 +119,62 @@ def test_shopping_lists_add_recipe(
|
||||||
assert refs[0]["recipeId"] == str(recipe.id)
|
assert refs[0]["recipeId"] == str(recipe.id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_shopping_list_add_recipe_scale(
|
||||||
|
api_client: TestClient,
|
||||||
|
unique_user: TestUser,
|
||||||
|
shopping_lists: list[ShoppingListOut],
|
||||||
|
recipe_ingredient_only: Recipe,
|
||||||
|
):
|
||||||
|
sample_list = random.choice(shopping_lists)
|
||||||
|
recipe = recipe_ingredient_only
|
||||||
|
|
||||||
|
response = api_client.post(
|
||||||
|
api_routes.groups_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id), headers=unique_user.token
|
||||||
|
)
|
||||||
|
|
||||||
|
response = api_client.get(api_routes.groups_shopping_lists_item_id(sample_list.id), headers=unique_user.token)
|
||||||
|
as_json = utils.assert_derserialize(response, 200)
|
||||||
|
|
||||||
|
assert len(as_json["recipeReferences"]) == 1
|
||||||
|
assert as_json["recipeReferences"][0]["recipeQuantity"] == 1
|
||||||
|
|
||||||
|
for item in as_json["listItems"]:
|
||||||
|
assert item["quantity"] == 1
|
||||||
|
refs = item["recipeReferences"]
|
||||||
|
|
||||||
|
# only one reference per item
|
||||||
|
assert len(refs) == 1
|
||||||
|
|
||||||
|
# base recipe quantity is 1
|
||||||
|
assert refs[0]["recipeQuantity"] == 1
|
||||||
|
|
||||||
|
# scale was unspecified, which defaults to 1
|
||||||
|
assert refs[0]["recipeScale"] == 1
|
||||||
|
|
||||||
|
recipe_scale = round(random.uniform(1, 10), 5)
|
||||||
|
payload = {"recipeIncrementQuantity": recipe_scale}
|
||||||
|
|
||||||
|
response = api_client.post(
|
||||||
|
api_routes.groups_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id),
|
||||||
|
headers=unique_user.token,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
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["recipeReferences"]) == 1
|
||||||
|
assert as_json["recipeReferences"][0]["recipeQuantity"] == 1 + recipe_scale
|
||||||
|
|
||||||
|
for item in as_json["listItems"]:
|
||||||
|
assert item["quantity"] == 1 + recipe_scale
|
||||||
|
refs = item["recipeReferences"]
|
||||||
|
|
||||||
|
assert len(refs) == 1
|
||||||
|
assert refs[0]["recipeQuantity"] == 1
|
||||||
|
assert refs[0]["recipeScale"] == 1 + recipe_scale
|
||||||
|
|
||||||
|
|
||||||
def test_shopping_lists_remove_recipe(
|
def test_shopping_lists_remove_recipe(
|
||||||
api_client: TestClient,
|
api_client: TestClient,
|
||||||
unique_user: TestUser,
|
unique_user: TestUser,
|
||||||
|
@ -205,6 +261,129 @@ def test_shopping_lists_remove_recipe_multiple_quantity(
|
||||||
assert refs[0]["recipeId"] == str(recipe.id)
|
assert refs[0]["recipeId"] == str(recipe.id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_shopping_list_remove_recipe_scale(
|
||||||
|
api_client: TestClient,
|
||||||
|
unique_user: TestUser,
|
||||||
|
shopping_lists: list[ShoppingListOut],
|
||||||
|
recipe_ingredient_only: Recipe,
|
||||||
|
):
|
||||||
|
sample_list = random.choice(shopping_lists)
|
||||||
|
recipe = recipe_ingredient_only
|
||||||
|
|
||||||
|
recipe_initital_scale = 100
|
||||||
|
payload = {"recipeIncrementQuantity": recipe_initital_scale}
|
||||||
|
|
||||||
|
# first add a bunch of quantity to the list
|
||||||
|
response = api_client.post(
|
||||||
|
api_routes.groups_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id),
|
||||||
|
headers=unique_user.token,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
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["recipeReferences"]) == 1
|
||||||
|
assert as_json["recipeReferences"][0]["recipeQuantity"] == recipe_initital_scale
|
||||||
|
|
||||||
|
for item in as_json["listItems"]:
|
||||||
|
assert item["quantity"] == recipe_initital_scale
|
||||||
|
refs = item["recipeReferences"]
|
||||||
|
|
||||||
|
assert len(refs) == 1
|
||||||
|
assert refs[0]["recipeQuantity"] == 1
|
||||||
|
assert refs[0]["recipeScale"] == recipe_initital_scale
|
||||||
|
|
||||||
|
recipe_decrement_scale = round(random.uniform(10, 90), 5)
|
||||||
|
payload = {"recipeDecrementQuantity": recipe_decrement_scale}
|
||||||
|
recipe_expected_scale = recipe_initital_scale - recipe_decrement_scale
|
||||||
|
|
||||||
|
# remove some of the recipes
|
||||||
|
response = api_client.delete(
|
||||||
|
api_routes.groups_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id),
|
||||||
|
headers=unique_user.token,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
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["recipeReferences"]) == 1
|
||||||
|
assert as_json["recipeReferences"][0]["recipeQuantity"] == recipe_expected_scale
|
||||||
|
|
||||||
|
for item in as_json["listItems"]:
|
||||||
|
assert item["quantity"] == recipe_expected_scale
|
||||||
|
refs = item["recipeReferences"]
|
||||||
|
|
||||||
|
assert len(refs) == 1
|
||||||
|
assert refs[0]["recipeQuantity"] == 1
|
||||||
|
assert refs[0]["recipeScale"] == recipe_expected_scale
|
||||||
|
|
||||||
|
|
||||||
|
def test_recipe_decrement_max(
|
||||||
|
api_client: TestClient,
|
||||||
|
unique_user: TestUser,
|
||||||
|
shopping_lists: list[ShoppingListOut],
|
||||||
|
recipe_ingredient_only: Recipe,
|
||||||
|
):
|
||||||
|
sample_list = random.choice(shopping_lists)
|
||||||
|
recipe = recipe_ingredient_only
|
||||||
|
|
||||||
|
recipe_scale = 10
|
||||||
|
payload = {"recipeIncrementQuantity": recipe_scale}
|
||||||
|
|
||||||
|
# first add a bunch of quantity to the list
|
||||||
|
response = api_client.post(
|
||||||
|
api_routes.groups_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id),
|
||||||
|
headers=unique_user.token,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
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["recipeReferences"]) == 1
|
||||||
|
assert as_json["recipeReferences"][0]["recipeQuantity"] == recipe_scale
|
||||||
|
|
||||||
|
for item in as_json["listItems"]:
|
||||||
|
assert item["quantity"] == recipe_scale
|
||||||
|
refs = item["recipeReferences"]
|
||||||
|
|
||||||
|
assert len(refs) == 1
|
||||||
|
assert refs[0]["recipeQuantity"] == 1
|
||||||
|
assert refs[0]["recipeScale"] == recipe_scale
|
||||||
|
|
||||||
|
# next add a little bit more of one item
|
||||||
|
item_additional_quantity = random_int(1, 10)
|
||||||
|
item_json = as_json["listItems"][0]
|
||||||
|
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
|
||||||
|
)
|
||||||
|
item_json = utils.assert_derserialize(response, 200)
|
||||||
|
assert item_json["quantity"] == recipe_scale + item_additional_quantity
|
||||||
|
|
||||||
|
# now remove way too many instances of the recipe
|
||||||
|
payload = {"recipeDecrementQuantity": recipe_scale * 100}
|
||||||
|
response = api_client.delete(
|
||||||
|
api_routes.groups_shopping_lists_item_id_recipe_recipe_id(sample_list.id, recipe.id),
|
||||||
|
headers=unique_user.token,
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# check that only the original recipe quantity and its reference were removed, not the additional quantity
|
||||||
|
assert len(as_json["recipeReferences"]) == 0
|
||||||
|
assert len(as_json["listItems"]) == 1
|
||||||
|
|
||||||
|
item = as_json["listItems"][0]
|
||||||
|
assert item["quantity"] == item_additional_quantity
|
||||||
|
assert len(item["recipeReferences"]) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_shopping_list_extras(
|
def test_shopping_list_extras(
|
||||||
api_client: TestClient,
|
api_client: TestClient,
|
||||||
unique_user: TestUser,
|
unique_user: TestUser,
|
||||||
|
|
|
@ -4,7 +4,7 @@ from mealie.core.config import get_app_settings
|
||||||
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
|
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
|
||||||
|
|
||||||
ALEMBIC_VERSIONS = [
|
ALEMBIC_VERSIONS = [
|
||||||
{"version_num": "1923519381ad"},
|
{"version_num": "167eb69066ad"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue