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

1from __future__ import annotations 

2 

3from datetime import datetime 

4from uuid import UUID 

5 

6from pydantic import UUID4, ConfigDict, field_validator, model_validator 

7from sqlalchemy.orm import joinedload, selectinload 

8from sqlalchemy.orm.interfaces import LoaderOption 

9 

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 

30 

31 

32class ShoppingListItemRecipeRefCreate(MealieModel): 

33 recipe_id: UUID4 

34 recipe_quantity: float = 0 

35 """the quantity of this item in a single recipe (scale == 1)""" 

36 

37 recipe_scale: NoneFloat = 1 

38 """the number of times this recipe has been added""" 

39 

40 recipe_note: str | None = None 

41 """the original note from the recipe""" 

42 

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 

47 

48 

49class ShoppingListItemRecipeRefUpdate(ShoppingListItemRecipeRefCreate): 

50 id: UUID4 

51 shopping_list_item_id: UUID4 

52 

53 

54class ShoppingListItemRecipeRefOut(ShoppingListItemRecipeRefUpdate): 

55 model_config = ConfigDict(from_attributes=True) 

56 

57 

58class ShoppingListItemBase(RecipeIngredientBase): 

59 shopping_list_id: UUID4 

60 checked: bool = False 

61 position: int = 0 

62 

63 quantity: float = 1 

64 

65 food_id: UUID4 | None = None 

66 label_id: UUID4 | None = None 

67 unit_id: UUID4 | None = None 

68 

69 extras: dict | None = {} 

70 

71 @field_validator("extras", mode="before") 

72 def convert_extras_to_dict(cls, v): 

73 if isinstance(v, dict): 

74 return v 

75 

76 return {x.key_name: x.value for x in v} if v else {} 

77 

78 

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] = [] 

83 

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 

89 

90 try: 

91 return UUID(v) 

92 except Exception: 

93 return None 

94 

95 

96class ShoppingListItemUpdate(ShoppingListItemBase): 

97 recipe_references: list[ShoppingListItemRecipeRefCreate | ShoppingListItemRecipeRefUpdate] = [] 

98 

99 

100class ShoppingListItemUpdateBulk(ShoppingListItemUpdate): 

101 """Only used for bulk update operations where the shopping list item id isn't already supplied""" 

102 

103 id: UUID4 

104 

105 

106class ShoppingListItemOut(ShoppingListItemBase): 

107 id: UUID4 

108 group_id: UUID4 

109 household_id: UUID4 

110 

111 food: IngredientFood | None = None 

112 label: MultiPurposeLabelSummary | None = None 

113 unit: IngredientUnit | None = None 

114 

115 recipe_references: list[ShoppingListItemRecipeRefOut] = [] 

116 

117 created_at: datetime | None = None 

118 updated_at: datetime | None = UpdatedAtField(None) 

119 

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 

126 

127 return self 

128 

129 model_config = ConfigDict(from_attributes=True) 

130 

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 ] 

144 

145 

146class ShoppingListItemsCollectionOut(MealieModel): 

147 """Container for bulk shopping list item changes""" 

148 

149 created_items: list[ShoppingListItemOut] = [] 

150 updated_items: list[ShoppingListItemOut] = [] 

151 deleted_items: list[ShoppingListItemOut] = [] 

152 

153 

154class ShoppingListMultiPurposeLabelCreate(MealieModel): 

155 shopping_list_id: UUID4 

156 label_id: UUID4 

157 position: int = 0 

158 

159 

160class ShoppingListMultiPurposeLabelUpdate(ShoppingListMultiPurposeLabelCreate): 

161 id: UUID4 

162 

163 

164class ShoppingListMultiPurposeLabelOut(ShoppingListMultiPurposeLabelUpdate): 

165 label: MultiPurposeLabelSummary 

166 model_config = ConfigDict(from_attributes=True) 

167 

168 @classmethod 

169 def loader_options(cls) -> list[LoaderOption]: 

170 return [joinedload(ShoppingListMultiPurposeLabel.label)] 

171 

172 

173class ShoppingListItemPagination(PaginationBase): 

174 items: list[ShoppingListItemOut] 

175 

176 

177class ShoppingListCreate(MealieModel): 

178 name: str | None = None 

179 extras: dict | None = {} 

180 

181 created_at: datetime | None = None 

182 updated_at: datetime | None = UpdatedAtField(None) 

183 

184 @field_validator("extras", mode="before") 

185 def convert_extras_to_dict(cls, v): 

186 if isinstance(v, dict): 

187 return v 

188 

189 return {x.key_name: x.value for x in v} if v else {} 

190 

191 

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

198 

199 recipe: RecipeSummary 

200 model_config = ConfigDict(from_attributes=True) 

201 

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 ] 

209 

210 

211class ShoppingListSave(ShoppingListCreate): 

212 group_id: UUID4 

213 user_id: UUID4 

214 

215 

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) 

222 

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 ] 

239 

240 

241class ShoppingListPagination(PaginationBase): 

242 items: list[ShoppingListSummary] 

243 

244 

245class ShoppingListUpdate(ShoppingListSave): 

246 id: UUID4 

247 list_items: list[ShoppingListItemOut] = [] 

248 

249 

250class ShoppingListOut(ShoppingListUpdate): 

251 household_id: UUID4 

252 recipe_references: list[ShoppingListRecipeRefOut] = [] 

253 label_settings: list[ShoppingListMultiPurposeLabelOut] = [] 

254 model_config = ConfigDict(from_attributes=True) 

255 

256 @field_validator("recipe_references", "label_settings", mode="before") 

257 def default_none_to_empty_list(cls, v): 

258 return v or [] 

259 

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 ] 

286 

287 

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

292 

293 

294class ShoppingListAddRecipeParamsBulk(ShoppingListAddRecipeParams): 

295 recipe_id: UUID4 

296 

297 

298class ShoppingListRemoveRecipeParams(MealieModel): 

299 recipe_decrement_quantity: float = 1