Refactor Shopping List API (#2021)

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

* fixed schema reference

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

* made fractions prettier

* replaced redundant display text util

* fixed edgecase for zero quantity items on a recipe

* fix for pre-merging recipe ingredients

* fixed edgecase for merging create_items together

* fixed bug with merged updated items creating dupes

* added test for self-removing recipe ref

* update items are now merged w/ existing items

* refactored service to make it easier to read

* added a lot of tests

* made it so checked items are never merged

* fixed bug with dragging + re-ordering

* fix for postgres cascade issue

* added prevalidator to recipe ref to avoid db error
This commit is contained in:
Michael Genson
2023-01-28 18:45:02 -06:00
committed by GitHub
parent 3415a9c310
commit 617cc1fdfb
18 changed files with 1398 additions and 576 deletions

View File

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

View File

@@ -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",
]

View File

@@ -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 = {}

View File

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