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

214 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 03:04 +0000

1from __future__ import annotations 

2 

3import datetime 

4import enum 

5from fractions import Fraction 

6from typing import ClassVar 

7from uuid import UUID, uuid4 

8 

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

10from sqlalchemy.orm import joinedload, selectinload 

11from sqlalchemy.orm.interfaces import LoaderOption 

12 

13from mealie.db.models.recipe import IngredientFoodModel 

14from mealie.schema._mealie import MealieModel 

15from mealie.schema._mealie.mealie_model import UpdatedAtField 

16from mealie.schema._mealie.types import NoneFloat 

17from mealie.schema.response.pagination import PaginationBase 

18 

19INGREDIENT_QTY_PRECISION = 3 

20MAX_INGREDIENT_DENOMINATOR = 32 

21 

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

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

24 

25 

26def display_fraction(fraction: Fraction): 

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

35 id: UUID4 | None = None 

36 name: str 

37 plural_name: str | None = None 

38 description: str = "" 

39 extras: dict | None = {} 

40 

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

42 def convert_empty_id_to_none(cls, v): 

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: 

46 v = None 

47 

48 return v 

49 

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

51 def convert_extras_to_dict(cls, v): 

52 if isinstance(v, dict): 

53 return v 

54 

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

56 

57 

58class CreateIngredientFoodAlias(MealieModel): 

59 name: str 

60 

61 

62class IngredientFoodAlias(CreateIngredientFoodAlias): 

63 model_config = ConfigDict(from_attributes=True) 

64 

65 

66class CreateIngredientFood(UnitFoodBase): 

67 label_id: UUID4 | None = None 

68 aliases: list[CreateIngredientFoodAlias] = [] 

69 households_with_ingredient_food: list[str] = [] 

70 

71 

72class SaveIngredientFood(CreateIngredientFood): 

73 group_id: UUID4 

74 

75 

76class IngredientFood(CreateIngredientFood): 

77 id: UUID4 

78 label: MultiPurposeLabelSummary | None = None 

79 aliases: list[IngredientFoodAlias] = [] 

80 

81 created_at: datetime.datetime | None = None 

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

83 

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

85 "name_normalized", 

86 "plural_name_normalized", 

87 ] 

88 _normalize_search: ClassVar[bool] = True 

89 model_config = ConfigDict(from_attributes=True) 

90 

91 @classmethod 

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

93 return [ 

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

100 def convert_households_to_slugs(cls, v): 

101 if not v: 

102 return [] 

103 

104 try: 

105 return [household.slug for household in v] 

106 except AttributeError: 

107 return v 

108 

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

110 return household_slug in self.households_with_tool 

111 

112 

113class IngredientFoodPagination(PaginationBase): 

114 items: list[IngredientFood] 

115 

116 

117class CreateIngredientUnitAlias(MealieModel): 

118 name: str 

119 

120 

121class IngredientUnitAlias(CreateIngredientUnitAlias): 

122 model_config = ConfigDict(from_attributes=True) 

123 

124 

125class CreateIngredientUnit(UnitFoodBase): 

126 fraction: bool = True 

127 abbreviation: str = "" 

128 plural_abbreviation: str | None = "" 

129 use_abbreviation: bool = False 

130 aliases: list[CreateIngredientUnitAlias] = [] 

131 

132 

133class SaveIngredientUnit(CreateIngredientUnit): 

134 group_id: UUID4 

135 

136 

137class IngredientUnit(CreateIngredientUnit): 

138 id: UUID4 

139 aliases: list[IngredientUnitAlias] = [] 

140 

141 created_at: datetime.datetime | None = None 

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

143 

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

145 "name_normalized", 

146 "plural_name_normalized", 

147 "abbreviation_normalized", 

148 "plural_abbreviation_normalized", 

149 ] 

150 _normalize_search: ClassVar[bool] = True 

151 model_config = ConfigDict(from_attributes=True) 

152 

153 

154class RecipeIngredientBase(MealieModel): 

155 quantity: NoneFloat = 0 

156 unit: IngredientUnit | CreateIngredientUnit | None = None 

157 food: IngredientFood | CreateIngredientFood | None = None 

158 note: str | None = "" 

159 

160 display: str = "" 

161 """ 

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

168 def format_display(self): 

169 if not self.display: 

170 self.display = self._format_display() 

171 

172 return self 

173 

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

175 @classmethod 

176 def validate_unit(cls, v): 

177 if isinstance(v, str): 

178 return CreateIngredientUnit(name=v) 

179 else: 

180 return v 

181 

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

183 @classmethod 

184 def validate_food(cls, v): 

185 if isinstance(v, str): 

186 return CreateIngredientFood(name=v) 

187 else: 

188 return v 

189 

190 def _format_quantity_for_display(self) -> str: 

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

192 

193 qty: float | Fraction 

194 

195 # decimal 

196 if self.unit and not self.unit.fraction: 

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) 

206 if qty.denominator == 1: 

207 return str(qty.numerator) 

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: 

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: 

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: 

233 if use_plural: 

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: 

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

242 return "" 

243 

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

245 if use_plural: 

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

247 else: 

248 return self.food.name 

249 

250 def _format_display(self) -> str: 

251 components = [] 

252 

253 if self.quantity: 

254 components.append(self._format_quantity_for_display()) 

255 

256 if self.quantity and self.unit: 

257 components.append(self._format_unit_for_display()) 

258 

259 if self.food: 

260 components.append(self._format_food_for_display()) 

261 

262 if self.note: 

263 components.append(self.note) 

264 

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

266 

267 

268class IngredientUnitPagination(PaginationBase): 

269 items: list[IngredientUnit] 

270 

271 

272class RecipeIngredient(RecipeIngredientBase): 

273 title: str | None = None 

274 original_text: str | None = None 

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) 

280 model_config = ConfigDict(from_attributes=True) 

281 

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

283 @classmethod 

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

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

291 return round(value, INGREDIENT_QTY_PRECISION) 

292 if value is None or value == "": 

293 return None 

294 return value 

295 

296 

297class IngredientConfidence(MealieModel): 

298 average: NoneFloat = None 

299 comment: NoneFloat = None 

300 name: NoneFloat = None 

301 unit: NoneFloat = None 

302 quantity: NoneFloat = None 

303 food: NoneFloat = None 

304 

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

306 @classmethod 

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

308 if isinstance(value, float): 

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

316 input: str | None = None 

317 confidence: IngredientConfidence = IngredientConfidence() 

318 ingredient: RecipeIngredient 

319 

320 

321class RegisteredParser(str, enum.Enum): 

322 nlp = "nlp" 

323 brute = "brute" 

324 openai = "openai" 

325 

326 

327class IngredientsRequest(MealieModel): 

328 parser: RegisteredParser = RegisteredParser.nlp 

329 ingredients: list[str] 

330 

331 

332class IngredientRequest(MealieModel): 

333 parser: RegisteredParser = RegisteredParser.nlp 

334 ingredient: str 

335 

336 

337class MergeFood(MealieModel): 

338 from_food: UUID4 

339 to_food: UUID4 

340 

341 

342class MergeUnit(MealieModel): 

343 from_unit: UUID4 

344 to_unit: UUID4 

345 

346 

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

348 

349IngredientFood.model_rebuild()