mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-28 05:05:12 -05: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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user