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:
Michael Genson 2022-12-30 16:47:35 -06:00 committed by GitHub
parent d9c39cc1d0
commit 46cc3898ab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 344 additions and 49 deletions

View file

@ -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 ###

View file

@ -51,6 +51,7 @@
color="info"
:card-menu="false"
:recipe-id="recipe.id"
:recipe-scale="recipeScale"
:use-items="{
delete: false,
edit: false,
@ -105,6 +106,10 @@ export default defineComponent({
required: true,
type: String,
},
recipeScale: {
type: Number,
default: 1,
},
open: {
required: true,
type: Boolean,

View file

@ -199,6 +199,10 @@ export default defineComponent({
required: true,
type: String,
},
recipeScale: {
type: Number,
default: 1,
},
/**
* Optional group ID prop that is only _required_ when the
* public URL is requested. If the public URL button is pressed
@ -316,7 +320,7 @@ export default defineComponent({
}
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) {
alert.success(i18n.t("recipe.recipe-added-to-list") as string);
state.shoppingListDialog = false;

View file

@ -1,7 +1,7 @@
<template>
<v-container :class="{ 'pa-0': $vuetify.breakpoint.smAndDown }">
<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" />
<v-card-text v-else>
<!--
@ -70,7 +70,7 @@
:recipe="recipe"
class="px-1 my-4 d-print-none"
/>
<RecipePrintView :recipe="recipe" />
<RecipePrintView :recipe="recipe" :scale="scale" />
</v-container>
</template>

View file

@ -45,6 +45,7 @@
v-if="user.id"
:recipe="recipe"
:slug="recipe.slug"
:recipe-scale="recipeScale"
:locked="user.id !== recipe.userId && recipe.settings.locked"
:name="recipe.name"
:logged-in="$auth.loggedIn"
@ -85,6 +86,10 @@ export default defineComponent({
type: Object as () => NoUndefinedField<Recipe>,
required: true,
},
recipeScale: {
type: Number,
default: 1,
},
landscape: {
type: Boolean,
default: false,

View file

@ -96,6 +96,10 @@ export default defineComponent({
type: Object as () => Recipe,
required: true,
},
scale: {
type: Number,
default: 1,
},
},
setup(props) {
// Group ingredients by section so we can style them independently
@ -181,7 +185,7 @@ export default defineComponent({
});
function parseText(ingredient: RecipeIngredient) {
return parseIngredientText(ingredient, props.recipe.settings?.disableAmount || false);
return parseIngredientText(ingredient, props.recipe.settings?.disableAmount || false, props.scale);
}
return {

View file

@ -24,8 +24,8 @@ export class ShoppingListsApi extends BaseCRUDAPI<ShoppingListCreate, ShoppingLi
baseRoute = routes.shoppingLists;
itemRoute = routes.shoppingListsId;
async addRecipe(itemId: string, recipeId: string) {
return await this.requests.post(routes.shoppingListIdAddRecipe(itemId, recipeId), {});
async addRecipe(itemId: string, recipeId: string, recipeIncrementQuantity = 1) {
return await this.requests.post(routes.shoppingListIdAddRecipe(itemId, recipeId), {recipeIncrementQuantity});
}
async removeRecipe(itemId: string, recipeId: string) {

View file

@ -17,6 +17,7 @@ class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase):
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True)
recipe = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs")
recipe_quantity = Column(Float, nullable=False)
recipe_scale = Column(Float, nullable=False, default=1)
@auto_init()
def __init__(self, **_) -> None:

View file

@ -7,12 +7,14 @@ from mealie.routes._base.base_controllers import BaseCrudController
from mealie.routes._base.controller import controller
from mealie.routes._base.mixins import HttpRepo
from mealie.schema.group.group_shopping_list import (
ShoppingListAddRecipeParams,
ShoppingListCreate,
ShoppingListItemCreate,
ShoppingListItemOut,
ShoppingListItemUpdate,
ShoppingListOut,
ShoppingListPagination,
ShoppingListRemoveRecipeParams,
ShoppingListSave,
ShoppingListSummary,
ShoppingListUpdate,
@ -218,13 +220,17 @@ class ShoppingListController(BaseCrudController):
# Other Operations
@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,
new_shopping_list_items,
updated_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:
self.publish_event(
@ -263,12 +269,16 @@ class ShoppingListController(BaseCrudController):
return shopping_list
@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,
updated_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:
self.publish_event(

View file

@ -15,6 +15,10 @@ from mealie.schema.response.pagination import PaginationBase
class ShoppingListItemRecipeRef(MealieModel):
recipe_id: UUID4
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):
@ -79,6 +83,8 @@ class ShoppingListRecipeRefOut(MealieModel):
shopping_list_id: UUID4
recipe_id: UUID4
recipe_quantity: float
"""the number of times this recipe has been added"""
recipe: RecipeSummary
class Config:
@ -118,6 +124,14 @@ class ShoppingListOut(ShoppingListUpdate):
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.recipe.recipe import RecipeSummary # noqa: E402

View file

@ -1,3 +1,5 @@
from typing import cast
from pydantic import UUID4
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 (
ShoppingListItemOut,
ShoppingListItemRecipeRef,
ShoppingListItemRecipeRefOut,
ShoppingListItemUpdate,
)
from mealie.schema.recipe import Recipe
class ShoppingListService:
@ -34,7 +38,7 @@ class ShoppingListService:
units_not_none = not units_is_none
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:
return item1.note == item2.note
@ -48,7 +52,7 @@ class ShoppingListService:
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
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):
if inner_index in checked_items:
continue
if ShoppingListService.can_merge(base_item, inner_item):
# Set Quantity
base_item.quantity += inner_item.quantity
# Set References
new_refs = []
for ref in inner_item.recipe_references:
ref.shopping_list_item_id = base_item.id # type: ignore
new_refs.append(ref)
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
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)
consolidated_list.append(base_item)
@ -111,7 +130,7 @@ class ShoppingListService:
# Methods
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]]:
"""
returns:
@ -120,7 +139,7 @@ class ShoppingListService:
- updated_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:
raise UnexpectedNone("Recipe not found")
@ -150,14 +169,13 @@ class ShoppingListService:
is_food=not recipe.settings.disable_amount if recipe.settings else False,
food_id=food_id,
unit_id=unit_id,
quantity=ingredient.quantity,
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_id=recipe_id, recipe_quantity=ingredient.quantity, recipe_scale=recipe_increment
)
],
)
@ -176,13 +194,16 @@ class ShoppingListService:
not_found = True
for refs in updated_shopping_list.recipe_references:
if refs.recipe_id == recipe_id:
refs.recipe_quantity += 1
if refs.recipe_id != recipe_id:
continue
refs.recipe_quantity += recipe_increment
not_found = False
break
if not_found:
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)
@ -217,7 +238,7 @@ class ShoppingListService:
return updated_shopping_list, new_shopping_list_items, updated_shopping_list_items, deleted_shopping_list_items
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]]:
"""
returns:
@ -225,8 +246,8 @@ class ShoppingListService:
- updated_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:
raise UnexpectedNone("Shopping list not found, cannot remove recipe ingredients")
@ -235,39 +256,62 @@ class ShoppingListService:
for item in shopping_list.list_items:
found = False
for ref in item.recipe_references:
remove_qty: None | float = 0.0
refs = cast(list[ShoppingListItemRecipeRefOut], item.recipe_references)
for ref in refs:
if ref.recipe_id != recipe_id:
continue
if ref.recipe_id == recipe_id:
self.list_item_refs.delete(ref.id) # type: ignore
# if the scale is missing we assume it's 1 for backwards compatibility
# 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)
found = True
remove_qty = ref.recipe_quantity
break # only remove one instance of the recipe for each item
break
# 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 remove_qty is not None:
item.quantity = item.quantity - remove_qty
if item.quantity <= 0:
self.list_items.delete(item.id)
deleted_shopping_list_items.append(item)
else:
self.list_items.update(item.id, item)
updated_shopping_list_items.append(item)
# Decrement the list recipe reference count
for recipe_ref in shopping_list.recipe_references:
if recipe_ref.recipe_id == recipe_id and recipe_ref.recipe_quantity is not None:
recipe_ref.recipe_quantity -= 1.0
if recipe_ref.recipe_id != recipe_id or recipe_ref.recipe_quantity is None:
continue
recipe_ref.recipe_quantity -= recipe_decrement
if recipe_ref.recipe_quantity <= 0.0:
self.list_refs.delete(recipe_ref.id)
else:
self.list_refs.update(recipe_ref.id, ref)
self.list_refs.update(recipe_ref.id, recipe_ref)
break
return (

View file

@ -6,7 +6,7 @@ from mealie.schema.group.group_shopping_list import ShoppingListOut
from mealie.schema.recipe.recipe import Recipe
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
@ -119,6 +119,62 @@ def test_shopping_lists_add_recipe(
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(
api_client: TestClient,
unique_user: TestUser,
@ -205,6 +261,129 @@ def test_shopping_lists_remove_recipe_multiple_quantity(
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(
api_client: TestClient,
unique_user: TestUser,

View file

@ -4,7 +4,7 @@ from mealie.core.config import get_app_settings
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
ALEMBIC_VERSIONS = [
{"version_num": "1923519381ad"},
{"version_num": "167eb69066ad"},
]