Coverage for opt/mealie/lib/python3.12/site-packages/mealie/schema/household/group_shopping_list.py: 100%
159 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 03:04 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 03:04 +0000
1from __future__ import annotations
3from datetime import datetime
4from uuid import UUID
6from pydantic import UUID4, ConfigDict, field_validator, model_validator
7from sqlalchemy.orm import joinedload, selectinload
8from sqlalchemy.orm.interfaces import LoaderOption
10from mealie.db.models.household import (
11 ShoppingList,
12 ShoppingListItem,
13 ShoppingListMultiPurposeLabel,
14 ShoppingListRecipeReference,
15)
16from mealie.db.models.recipe import IngredientFoodModel, RecipeModel
17from mealie.db.models.users.users import User
18from mealie.schema._mealie import MealieModel
19from mealie.schema._mealie.mealie_model import UpdatedAtField
20from mealie.schema._mealie.types import NoneFloat
21from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary
22from mealie.schema.recipe.recipe import RecipeSummary
23from mealie.schema.recipe.recipe_ingredient import (
24 IngredientFood,
25 IngredientUnit,
26 RecipeIngredient,
27 RecipeIngredientBase,
28)
29from mealie.schema.response.pagination import PaginationBase
32class ShoppingListItemRecipeRefCreate(MealieModel):
33 recipe_id: UUID4
34 recipe_quantity: float = 0
35 """the quantity of this item in a single recipe (scale == 1)"""
37 recipe_scale: NoneFloat = 1
38 """the number of times this recipe has been added"""
40 recipe_note: str | None = None
41 """the original note from the recipe"""
43 @field_validator("recipe_quantity", mode="before")
44 @classmethod
45 def default_none_to_zero(cls, v):
46 return 0 if v is None else v
49class ShoppingListItemRecipeRefUpdate(ShoppingListItemRecipeRefCreate):
50 id: UUID4
51 shopping_list_item_id: UUID4
54class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRefUpdate):
55 model_config = ConfigDict(from_attributes=True)
58class ShoppingListItemBase(RecipeIngredientBase):
59 shopping_list_id: UUID4
60 checked: bool = False
61 position: int = 0
63 quantity: float = 1
65 food_id: UUID4 | None = None
66 label_id: UUID4 | None = None
67 unit_id: UUID4 | None = None
69 extras: dict | None = {}
71 @field_validator("extras", mode="before")
72 def convert_extras_to_dict(cls, v):
73 if isinstance(v, dict):
74 return v
76 return {x.key_name: x.value for x in v} if v else {}
79class ShoppingListItemCreate(ShoppingListItemBase):
80 id: UUID4 | None = None
81 """The unique id of the item to create. If not supplied, one will be generated."""
82 recipe_references: list[ShoppingListItemRecipeRefCreate] = []
84 @field_validator("id", mode="before")
85 def validate_id(cls, v):
86 v = v or None
87 if not v or isinstance(v, UUID):
88 return v
90 try:
91 return UUID(v)
92 except Exception:
93 return None
96class ShoppingListItemUpdate(ShoppingListItemBase):
97 recipe_references: list[ShoppingListItemRecipeRefCreate | ShoppingListItemRecipeRefUpdate] = []
100class ShoppingListItemUpdateBulk(ShoppingListItemUpdate):
101 """Only used for bulk update operations where the shopping list item id isn't already supplied"""
103 id: UUID4
106class ShoppingListItemOut(ShoppingListItemBase):
107 id: UUID4
108 group_id: UUID4
109 household_id: UUID4
111 food: IngredientFood | None = None
112 label: MultiPurposeLabelSummary | None = None
113 unit: IngredientUnit | None = None
115 recipe_references: list[ShoppingListItemRecipeRefOut] = []
117 created_at: datetime | None = None
118 updated_at: datetime | None = UpdatedAtField(None)
120 @model_validator(mode="after")
121 def populate_missing_label(self):
122 # if we're missing a label, but the food has a label, use that as the label
123 if (not self.label) and (self.food and self.food.label):
124 self.label = self.food.label
125 self.label_id = self.label.id
127 return self
129 model_config = ConfigDict(from_attributes=True)
131 @classmethod
132 def loader_options(cls) -> list[LoaderOption]:
133 return [
134 selectinload(ShoppingListItem.extras),
135 selectinload(ShoppingListItem.food).joinedload(IngredientFoodModel.extras),
136 selectinload(ShoppingListItem.food).joinedload(IngredientFoodModel.label),
137 joinedload(ShoppingListItem.label),
138 joinedload(ShoppingListItem.unit),
139 selectinload(ShoppingListItem.recipe_references),
140 joinedload(ShoppingListItem.shopping_list)
141 .joinedload(ShoppingList.user)
142 .load_only(User.household_id, User.group_id),
143 ]
146class ShoppingListItemsCollectionOut(MealieModel):
147 """Container for bulk shopping list item changes"""
149 created_items: list[ShoppingListItemOut] = []
150 updated_items: list[ShoppingListItemOut] = []
151 deleted_items: list[ShoppingListItemOut] = []
154class ShoppingListMultiPurposeLabelCreate(MealieModel):
155 shopping_list_id: UUID4
156 label_id: UUID4
157 position: int = 0
160class ShoppingListMultiPurposeLabelUpdate(ShoppingListMultiPurposeLabelCreate):
161 id: UUID4
164class ShoppingListMultiPurposeLabelOut(ShoppingListMultiPurposeLabelUpdate):
165 label: MultiPurposeLabelSummary
166 model_config = ConfigDict(from_attributes=True)
168 @classmethod
169 def loader_options(cls) -> list[LoaderOption]:
170 return [joinedload(ShoppingListMultiPurposeLabel.label)]
173class ShoppingListItemPagination(PaginationBase):
174 items: list[ShoppingListItemOut]
177class ShoppingListCreate(MealieModel):
178 name: str | None = None
179 extras: dict | None = {}
181 created_at: datetime | None = None
182 updated_at: datetime | None = UpdatedAtField(None)
184 @field_validator("extras", mode="before")
185 def convert_extras_to_dict(cls, v):
186 if isinstance(v, dict):
187 return v
189 return {x.key_name: x.value for x in v} if v else {}
192class ShoppingListRecipeRefOut(MealieModel):
193 id: UUID4
194 shopping_list_id: UUID4
195 recipe_id: UUID4
196 recipe_quantity: float
197 """the number of times this recipe has been added"""
199 recipe: RecipeSummary
200 model_config = ConfigDict(from_attributes=True)
202 @classmethod
203 def loader_options(cls) -> list[LoaderOption]:
204 return [
205 selectinload(ShoppingListRecipeReference.recipe).joinedload(RecipeModel.recipe_category),
206 selectinload(ShoppingListRecipeReference.recipe).joinedload(RecipeModel.tags),
207 selectinload(ShoppingListRecipeReference.recipe).joinedload(RecipeModel.tools),
208 ]
211class ShoppingListSave(ShoppingListCreate):
212 group_id: UUID4
213 user_id: UUID4
216class ShoppingListSummary(ShoppingListSave):
217 id: UUID4
218 household_id: UUID4
219 recipe_references: list[ShoppingListRecipeRefOut]
220 label_settings: list[ShoppingListMultiPurposeLabelOut]
221 model_config = ConfigDict(from_attributes=True)
223 @classmethod
224 def loader_options(cls) -> list[LoaderOption]:
225 return [
226 selectinload(ShoppingList.extras),
227 selectinload(ShoppingList.recipe_references)
228 .joinedload(ShoppingListRecipeReference.recipe)
229 .joinedload(RecipeModel.recipe_category),
230 selectinload(ShoppingList.recipe_references)
231 .joinedload(ShoppingListRecipeReference.recipe)
232 .joinedload(RecipeModel.tags),
233 selectinload(ShoppingList.recipe_references)
234 .joinedload(ShoppingListRecipeReference.recipe)
235 .joinedload(RecipeModel.tools),
236 selectinload(ShoppingList.label_settings).joinedload(ShoppingListMultiPurposeLabel.label),
237 joinedload(ShoppingList.user).load_only(User.household_id, User.group_id),
238 ]
241class ShoppingListPagination(PaginationBase):
242 items: list[ShoppingListSummary]
245class ShoppingListUpdate(ShoppingListSave):
246 id: UUID4
247 list_items: list[ShoppingListItemOut] = []
250class ShoppingListOut(ShoppingListUpdate):
251 household_id: UUID4
252 recipe_references: list[ShoppingListRecipeRefOut] = []
253 label_settings: list[ShoppingListMultiPurposeLabelOut] = []
254 model_config = ConfigDict(from_attributes=True)
256 @field_validator("recipe_references", "label_settings", mode="before")
257 def default_none_to_empty_list(cls, v):
258 return v or []
260 @classmethod
261 def loader_options(cls) -> list[LoaderOption]:
262 return [
263 selectinload(ShoppingList.extras),
264 selectinload(ShoppingList.list_items).joinedload(ShoppingListItem.extras),
265 selectinload(ShoppingList.list_items)
266 .joinedload(ShoppingListItem.food)
267 .joinedload(IngredientFoodModel.extras),
268 selectinload(ShoppingList.list_items)
269 .joinedload(ShoppingListItem.food)
270 .joinedload(IngredientFoodModel.label),
271 selectinload(ShoppingList.list_items).joinedload(ShoppingListItem.label),
272 selectinload(ShoppingList.list_items).joinedload(ShoppingListItem.unit),
273 selectinload(ShoppingList.list_items).joinedload(ShoppingListItem.recipe_references),
274 selectinload(ShoppingList.recipe_references)
275 .joinedload(ShoppingListRecipeReference.recipe)
276 .joinedload(RecipeModel.recipe_category),
277 selectinload(ShoppingList.recipe_references)
278 .joinedload(ShoppingListRecipeReference.recipe)
279 .joinedload(RecipeModel.tags),
280 selectinload(ShoppingList.recipe_references)
281 .joinedload(ShoppingListRecipeReference.recipe)
282 .joinedload(RecipeModel.tools),
283 selectinload(ShoppingList.label_settings).joinedload(ShoppingListMultiPurposeLabel.label),
284 joinedload(ShoppingList.user).load_only(User.household_id, User.group_id),
285 ]
288class ShoppingListAddRecipeParams(MealieModel):
289 recipe_increment_quantity: float = 1
290 recipe_ingredients: list[RecipeIngredient] | None = None
291 """optionally override which ingredients are added from the recipe"""
294class ShoppingListAddRecipeParamsBulk(ShoppingListAddRecipeParams):
295 recipe_id: UUID4
298class ShoppingListRemoveRecipeParams(MealieModel):
299 recipe_decrement_quantity: float = 1