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
« prev ^ index » next coverage.py v7.10.6, created at 2025-11-25 15:32 +0000
1from __future__ import annotations 1a
3import datetime 1a
4import enum 1a
5from fractions import Fraction 1a
6from typing import ClassVar 1a
7from uuid import UUID, uuid4 1a
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
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
19INGREDIENT_QTY_PRECISION = 3 1a
20MAX_INGREDIENT_DENOMINATOR = 32 1a
22SUPERSCRIPT = dict(zip("1234567890", "¹²³⁴⁵⁶⁷⁸⁹⁰", strict=False)) 1a
23SUBSCRIPT = dict(zip("1234567890", "₁₂₃₄₅₆₇₈₉₀", strict=False)) 1a
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 )
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
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
48 return v 1lefdcghijkb
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
55 return {x.key_name: x.value for x in v} if v else {} 1lefdcghijkb
58class CreateIngredientFoodAlias(MealieModel): 1a
59 name: str 1a
62class IngredientFoodAlias(CreateIngredientFoodAlias): 1a
63 model_config = ConfigDict(from_attributes=True) 1a
66class CreateIngredientFood(UnitFoodBase): 1a
67 label_id: UUID4 | None = None 1a
68 aliases: list[CreateIngredientFoodAlias] = [] 1a
69 households_with_ingredient_food: list[str] = [] 1a
72class SaveIngredientFood(CreateIngredientFood): 1a
73 group_id: UUID4 1a
76class IngredientFood(CreateIngredientFood): 1a
77 id: UUID4 1a
78 label: MultiPurposeLabelSummary | None = None 1a
79 aliases: list[IngredientFoodAlias] = [] 1a
81 created_at: datetime.datetime | None = None 1a
82 updated_at: datetime.datetime | None = UpdatedAtField(None) 1a
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
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 ]
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
104 try: 1cb
105 return [household.slug for household in v] 1cb
106 except AttributeError: 1cb
107 return v 1cb
109 def is_on_hand(self, household_slug: str) -> bool: 1a
110 return household_slug in self.households_with_tool
113class IngredientFoodPagination(PaginationBase): 1a
114 items: list[IngredientFood] 1a
117class CreateIngredientUnitAlias(MealieModel): 1a
118 name: str 1a
121class IngredientUnitAlias(CreateIngredientUnitAlias): 1a
122 model_config = ConfigDict(from_attributes=True) 1a
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
133class SaveIngredientUnit(CreateIngredientUnit): 1a
134 group_id: UUID4 1a
137class IngredientUnit(CreateIngredientUnit): 1a
138 id: UUID4 1a
139 aliases: list[IngredientUnitAlias] = [] 1a
141 created_at: datetime.datetime | None = None 1a
142 updated_at: datetime.datetime | None = UpdatedAtField(None) 1a
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
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
160 display: str = "" 1a
161 """ 1a
162 How the ingredient should be displayed
164 Automatically calculated after the object is created, unless overwritten
165 """
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
172 return self 1dcb
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
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
190 def _format_quantity_for_display(self) -> str: 1a
191 """How the quantity should be displayed"""
193 qty: float | Fraction
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))
201 else:
202 return str(qty)
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
209 if qty.numerator <= qty.denominator:
210 return display_fraction(qty)
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
218 return f"{whole_number} {display_fraction(qty)}"
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 ""
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
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
238 return unit_val
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 ""
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
250 def _format_display(self) -> str: 1a
251 components = [] 1dcb
253 if self.quantity: 1dcb
254 components.append(self._format_quantity_for_display()) 1cb
256 if self.quantity and self.unit: 1dcb
257 components.append(self._format_unit_for_display())
259 if self.food: 1dcb
260 components.append(self._format_food_for_display()) 1cb
262 if self.note: 1dcb
263 components.append(self.note)
265 return " ".join(components).strip() 1dcb
268class IngredientUnitPagination(PaginationBase): 1a
269 items: list[IngredientUnit] 1a
272class RecipeIngredient(RecipeIngredientBase): 1a
273 title: str | None = None 1a
274 original_text: str | None = None 1a
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
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
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
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
315class ParsedIngredient(MealieModel): 1a
316 input: str | None = None 1a
317 confidence: IngredientConfidence = IngredientConfidence() 1a
318 ingredient: RecipeIngredient 1a
321class RegisteredParser(str, enum.Enum): 1a
322 nlp = "nlp" 1a
323 brute = "brute" 1a
324 openai = "openai" 1a
327class IngredientsRequest(MealieModel): 1a
328 parser: RegisteredParser = RegisteredParser.nlp 1a
329 ingredients: list[str] 1a
332class IngredientRequest(MealieModel): 1a
333 parser: RegisteredParser = RegisteredParser.nlp 1a
334 ingredient: str 1a
337class MergeFood(MealieModel): 1a
338 from_food: UUID4 1a
339 to_food: UUID4 1a
342class MergeUnit(MealieModel): 1a
343 from_unit: UUID4 1a
344 to_unit: UUID4 1a
347from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary # noqa: E402 1a
349IngredientFood.model_rebuild() 1a