Coverage for opt/mealie/lib/python3.12/site-packages/mealie/schema/recipe/recipe_ingredient.py: 83%

214 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-11-25 15:32 +0000

1from __future__ import annotations 1a

2 

3import datetime 1a

4import enum 1a

5from fractions import Fraction 1a

6from typing import ClassVar 1a

7from uuid import UUID, uuid4 1a

8 

9from pydantic import UUID4, ConfigDict, Field, field_validator, model_validator 1a

10from sqlalchemy.orm import joinedload, selectinload 1a

11from sqlalchemy.orm.interfaces import LoaderOption 1a

12 

13from mealie.db.models.recipe import IngredientFoodModel 1a

14from mealie.schema._mealie import MealieModel 1a

15from mealie.schema._mealie.mealie_model import UpdatedAtField 1a

16from mealie.schema._mealie.types import NoneFloat 1a

17from mealie.schema.response.pagination import PaginationBase 1a

18 

19INGREDIENT_QTY_PRECISION = 3 1a

20MAX_INGREDIENT_DENOMINATOR = 32 1a

21 

22SUPERSCRIPT = dict(zip("1234567890", "¹²³⁴⁵⁶⁷⁸⁹⁰", strict=False)) 1a

23SUBSCRIPT = dict(zip("1234567890", "₁₂₃₄₅₆₇₈₉₀", strict=False)) 1a

24 

25 

26def display_fraction(fraction: Fraction): 1a

27 return ( 

28 "".join([SUPERSCRIPT[c] for c in str(fraction.numerator)]) 

29 + "/" 

30 + "".join([SUBSCRIPT[c] for c in str(fraction.denominator)]) 

31 ) 

32 

33 

34class UnitFoodBase(MealieModel): 1a

35 id: UUID4 | None = None 1a

36 name: str 1a

37 plural_name: str | None = None 1a

38 description: str = "" 1a

39 extras: dict | None = {} 1a

40 

41 @field_validator("id", mode="before") 1a

42 def convert_empty_id_to_none(cls, v): 1a

43 # sometimes the frontend will give us an empty string instead of null, so we convert it to None, 

44 # otherwise Pydantic will try to convert it to a UUID and fail 

45 if not v: 1lefdcghijkb

46 v = None 1lefdcghijkb

47 

48 return v 1lefdcghijkb

49 

50 @field_validator("extras", mode="before") 1a

51 def convert_extras_to_dict(cls, v): 1a

52 if isinstance(v, dict): 1lefdcghijkb

53 return v 1lefdcghijkb

54 

55 return {x.key_name: x.value for x in v} if v else {} 1lefdcghijkb

56 

57 

58class CreateIngredientFoodAlias(MealieModel): 1a

59 name: str 1a

60 

61 

62class IngredientFoodAlias(CreateIngredientFoodAlias): 1a

63 model_config = ConfigDict(from_attributes=True) 1a

64 

65 

66class CreateIngredientFood(UnitFoodBase): 1a

67 label_id: UUID4 | None = None 1a

68 aliases: list[CreateIngredientFoodAlias] = [] 1a

69 households_with_ingredient_food: list[str] = [] 1a

70 

71 

72class SaveIngredientFood(CreateIngredientFood): 1a

73 group_id: UUID4 1a

74 

75 

76class IngredientFood(CreateIngredientFood): 1a

77 id: UUID4 1a

78 label: MultiPurposeLabelSummary | None = None 1a

79 aliases: list[IngredientFoodAlias] = [] 1a

80 

81 created_at: datetime.datetime | None = None 1a

82 updated_at: datetime.datetime | None = UpdatedAtField(None) 1a

83 

84 _searchable_properties: ClassVar[list[str]] = [ 1a

85 "name_normalized", 

86 "plural_name_normalized", 

87 ] 

88 _normalize_search: ClassVar[bool] = True 1a

89 model_config = ConfigDict(from_attributes=True) 1a

90 

91 @classmethod 1a

92 def loader_options(cls) -> list[LoaderOption]: 1a

93 return [ 1mefdcghijkb

94 selectinload(IngredientFoodModel.households_with_ingredient_food), 

95 joinedload(IngredientFoodModel.extras), 

96 joinedload(IngredientFoodModel.label), 

97 ] 

98 

99 @field_validator("households_with_ingredient_food", mode="before") 1a

100 def convert_households_to_slugs(cls, v): 1a

101 if not v: 1efdcghijkb

102 return [] 1efdcghijkb

103 

104 try: 1cb

105 return [household.slug for household in v] 1cb

106 except AttributeError: 1cb

107 return v 1cb

108 

109 def is_on_hand(self, household_slug: str) -> bool: 1a

110 return household_slug in self.households_with_tool 

111 

112 

113class IngredientFoodPagination(PaginationBase): 1a

114 items: list[IngredientFood] 1a

115 

116 

117class CreateIngredientUnitAlias(MealieModel): 1a

118 name: str 1a

119 

120 

121class IngredientUnitAlias(CreateIngredientUnitAlias): 1a

122 model_config = ConfigDict(from_attributes=True) 1a

123 

124 

125class CreateIngredientUnit(UnitFoodBase): 1a

126 fraction: bool = True 1a

127 abbreviation: str = "" 1a

128 plural_abbreviation: str | None = "" 1a

129 use_abbreviation: bool = False 1a

130 aliases: list[CreateIngredientUnitAlias] = [] 1a

131 

132 

133class SaveIngredientUnit(CreateIngredientUnit): 1a

134 group_id: UUID4 1a

135 

136 

137class IngredientUnit(CreateIngredientUnit): 1a

138 id: UUID4 1a

139 aliases: list[IngredientUnitAlias] = [] 1a

140 

141 created_at: datetime.datetime | None = None 1a

142 updated_at: datetime.datetime | None = UpdatedAtField(None) 1a

143 

144 _searchable_properties: ClassVar[list[str]] = [ 1a

145 "name_normalized", 

146 "plural_name_normalized", 

147 "abbreviation_normalized", 

148 "plural_abbreviation_normalized", 

149 ] 

150 _normalize_search: ClassVar[bool] = True 1a

151 model_config = ConfigDict(from_attributes=True) 1a

152 

153 

154class RecipeIngredientBase(MealieModel): 1a

155 quantity: NoneFloat = 0 1a

156 unit: IngredientUnit | CreateIngredientUnit | None = None 1a

157 food: IngredientFood | CreateIngredientFood | None = None 1a

158 note: str | None = "" 1a

159 

160 display: str = "" 1a

161 """ 1a

162 How the ingredient should be displayed 

163 

164 Automatically calculated after the object is created, unless overwritten 

165 """ 

166 

167 @model_validator(mode="after") 1a

168 def format_display(self): 1a

169 if not self.display: 1dcb

170 self.display = self._format_display() 1dcb

171 

172 return self 1dcb

173 

174 @field_validator("unit", mode="before") 1a

175 @classmethod 1a

176 def validate_unit(cls, v): 1a

177 if isinstance(v, str): 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true1dcb

178 return CreateIngredientUnit(name=v) 

179 else: 

180 return v 1dcb

181 

182 @field_validator("food", mode="before") 1a

183 @classmethod 1a

184 def validate_food(cls, v): 1a

185 if isinstance(v, str): 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true1dcb

186 return CreateIngredientFood(name=v) 

187 else: 

188 return v 1dcb

189 

190 def _format_quantity_for_display(self) -> str: 1a

191 """How the quantity should be displayed""" 

192 

193 qty: float | Fraction 

194 

195 # decimal 

196 if self.unit and not self.unit.fraction: 196 ↛ 197line 196 didn't jump to line 197 because the condition on line 196 was never true1cb

197 qty = round(self.quantity or 0, INGREDIENT_QTY_PRECISION) 

198 if qty.is_integer(): 

199 return str(int(qty)) 

200 

201 else: 

202 return str(qty) 

203 

204 # fraction 

205 qty = Fraction(self.quantity or 0).limit_denominator(MAX_INGREDIENT_DENOMINATOR) 1cb

206 if qty.denominator == 1: 206 ↛ 209line 206 didn't jump to line 209 because the condition on line 206 was always true1cb

207 return str(qty.numerator) 1cb

208 

209 if qty.numerator <= qty.denominator: 

210 return display_fraction(qty) 

211 

212 # convert an improper fraction into a mixed fraction (e.g. 11/4 --> 2 3/4) 

213 whole_number = 0 

214 while qty.numerator > qty.denominator: 

215 whole_number += 1 

216 qty -= 1 

217 

218 return f"{whole_number} {display_fraction(qty)}" 

219 

220 def _format_unit_for_display(self) -> str: 1a

221 if not self.unit: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true

222 return "" 

223 

224 use_plural = self.quantity and self.quantity > 1 

225 unit_val = "" 

226 if self.unit.use_abbreviation: 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true

227 if use_plural: 

228 unit_val = self.unit.plural_abbreviation or self.unit.abbreviation 

229 else: 

230 unit_val = self.unit.abbreviation 

231 

232 if not unit_val: 232 ↛ 238line 232 didn't jump to line 238 because the condition on line 232 was always true

233 if use_plural: 233 ↛ 236line 233 didn't jump to line 236 because the condition on line 233 was always true

234 unit_val = self.unit.plural_name or self.unit.name 

235 else: 

236 unit_val = self.unit.name 

237 

238 return unit_val 

239 

240 def _format_food_for_display(self) -> str: 1a

241 if not self.food: 241 ↛ 242line 241 didn't jump to line 242 because the condition on line 241 was never true1cb

242 return "" 

243 

244 use_plural = (not self.quantity) or self.quantity > 1 1cb

245 if use_plural: 245 ↛ 248line 245 didn't jump to line 248 because the condition on line 245 was always true1cb

246 return self.food.plural_name or self.food.name 1cb

247 else: 

248 return self.food.name 

249 

250 def _format_display(self) -> str: 1a

251 components = [] 1dcb

252 

253 if self.quantity: 1dcb

254 components.append(self._format_quantity_for_display()) 1cb

255 

256 if self.quantity and self.unit: 1dcb

257 components.append(self._format_unit_for_display()) 

258 

259 if self.food: 1dcb

260 components.append(self._format_food_for_display()) 1cb

261 

262 if self.note: 1dcb

263 components.append(self.note) 

264 

265 return " ".join(components).strip() 1dcb

266 

267 

268class IngredientUnitPagination(PaginationBase): 1a

269 items: list[IngredientUnit] 1a

270 

271 

272class RecipeIngredient(RecipeIngredientBase): 1a

273 title: str | None = None 1a

274 original_text: str | None = None 1a

275 

276 # Ref is used as a way to distinguish between an individual ingredient on the frontend 

277 # It is required for the reorder and section titles to function properly because of how 

278 # Vue handles reactivity. ref may serve another purpose in the future. 

279 reference_id: UUID = Field(default_factory=uuid4) 1a

280 model_config = ConfigDict(from_attributes=True) 1a

281 

282 @field_validator("quantity", mode="before") 1a

283 @classmethod 1a

284 def validate_quantity(cls, value) -> NoneFloat: 1a

285 """ 

286 Sometimes the frontend UI will provide an empty string as a "null" value because of the default 

287 bindings in Vue. This validator will ensure that the quantity is set to None if the value is an 

288 empty string. 

289 """ 

290 if isinstance(value, float): 1cb

291 return round(value, INGREDIENT_QTY_PRECISION) 1cb

292 if value is None or value == "": 292 ↛ 294line 292 didn't jump to line 294 because the condition on line 292 was always true1cb

293 return None 1cb

294 return value 

295 

296 

297class IngredientConfidence(MealieModel): 1a

298 average: NoneFloat = None 1a

299 comment: NoneFloat = None 1a

300 name: NoneFloat = None 1a

301 unit: NoneFloat = None 1a

302 quantity: NoneFloat = None 1a

303 food: NoneFloat = None 1a

304 

305 @field_validator("quantity", mode="before") 1a

306 @classmethod 1a

307 def validate_quantity(cls, value, values) -> NoneFloat: 1a

308 if isinstance(value, float): 308 ↛ 309line 308 didn't jump to line 309 because the condition on line 308 was never true

309 return round(value, INGREDIENT_QTY_PRECISION) 

310 if value is None or value == "": 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true

311 return None 

312 return value 

313 

314 

315class ParsedIngredient(MealieModel): 1a

316 input: str | None = None 1a

317 confidence: IngredientConfidence = IngredientConfidence() 1a

318 ingredient: RecipeIngredient 1a

319 

320 

321class RegisteredParser(str, enum.Enum): 1a

322 nlp = "nlp" 1a

323 brute = "brute" 1a

324 openai = "openai" 1a

325 

326 

327class IngredientsRequest(MealieModel): 1a

328 parser: RegisteredParser = RegisteredParser.nlp 1a

329 ingredients: list[str] 1a

330 

331 

332class IngredientRequest(MealieModel): 1a

333 parser: RegisteredParser = RegisteredParser.nlp 1a

334 ingredient: str 1a

335 

336 

337class MergeFood(MealieModel): 1a

338 from_food: UUID4 1a

339 to_food: UUID4 1a

340 

341 

342class MergeUnit(MealieModel): 1a

343 from_unit: UUID4 1a

344 to_unit: UUID4 1a

345 

346 

347from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary # noqa: E402 1a

348 

349IngredientFood.model_rebuild() 1a