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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 03:04 +0000
1from __future__ import annotations
3import datetime
4import enum
5from fractions import Fraction
6from typing import ClassVar
7from uuid import UUID, uuid4
9from pydantic import UUID4, ConfigDict, Field, field_validator, model_validator
10from sqlalchemy.orm import joinedload, selectinload
11from sqlalchemy.orm.interfaces import LoaderOption
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
19INGREDIENT_QTY_PRECISION = 3
20MAX_INGREDIENT_DENOMINATOR = 32
22SUPERSCRIPT = dict(zip("1234567890", "¹²³⁴⁵⁶⁷⁸⁹⁰", strict=False))
23SUBSCRIPT = dict(zip("1234567890", "₁₂₃₄₅₆₇₈₉₀", strict=False))
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 )
34class UnitFoodBase(MealieModel):
35 id: UUID4 | None = None
36 name: str
37 plural_name: str | None = None
38 description: str = ""
39 extras: dict | None = {}
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
48 return v
50 @field_validator("extras", mode="before")
51 def convert_extras_to_dict(cls, v):
52 if isinstance(v, dict):
53 return v
55 return {x.key_name: x.value for x in v} if v else {}
58class CreateIngredientFoodAlias(MealieModel):
59 name: str
62class IngredientFoodAlias(CreateIngredientFoodAlias):
63 model_config = ConfigDict(from_attributes=True)
66class CreateIngredientFood(UnitFoodBase):
67 label_id: UUID4 | None = None
68 aliases: list[CreateIngredientFoodAlias] = []
69 households_with_ingredient_food: list[str] = []
72class SaveIngredientFood(CreateIngredientFood):
73 group_id: UUID4
76class IngredientFood(CreateIngredientFood):
77 id: UUID4
78 label: MultiPurposeLabelSummary | None = None
79 aliases: list[IngredientFoodAlias] = []
81 created_at: datetime.datetime | None = None
82 updated_at: datetime.datetime | None = UpdatedAtField(None)
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)
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 ]
99 @field_validator("households_with_ingredient_food", mode="before")
100 def convert_households_to_slugs(cls, v):
101 if not v:
102 return []
104 try:
105 return [household.slug for household in v]
106 except AttributeError:
107 return v
109 def is_on_hand(self, household_slug: str) -> bool:
110 return household_slug in self.households_with_tool
113class IngredientFoodPagination(PaginationBase):
114 items: list[IngredientFood]
117class CreateIngredientUnitAlias(MealieModel):
118 name: str
121class IngredientUnitAlias(CreateIngredientUnitAlias):
122 model_config = ConfigDict(from_attributes=True)
125class CreateIngredientUnit(UnitFoodBase):
126 fraction: bool = True
127 abbreviation: str = ""
128 plural_abbreviation: str | None = ""
129 use_abbreviation: bool = False
130 aliases: list[CreateIngredientUnitAlias] = []
133class SaveIngredientUnit(CreateIngredientUnit):
134 group_id: UUID4
137class IngredientUnit(CreateIngredientUnit):
138 id: UUID4
139 aliases: list[IngredientUnitAlias] = []
141 created_at: datetime.datetime | None = None
142 updated_at: datetime.datetime | None = UpdatedAtField(None)
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)
154class RecipeIngredientBase(MealieModel):
155 quantity: NoneFloat = 0
156 unit: IngredientUnit | CreateIngredientUnit | None = None
157 food: IngredientFood | CreateIngredientFood | None = None
158 note: str | None = ""
160 display: str = ""
161 """
162 How the ingredient should be displayed
164 Automatically calculated after the object is created, unless overwritten
165 """
167 @model_validator(mode="after")
168 def format_display(self):
169 if not self.display:
170 self.display = self._format_display()
172 return self
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
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
190 def _format_quantity_for_display(self) -> str:
191 """How the quantity should be displayed"""
193 qty: float | Fraction
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))
201 else:
202 return str(qty)
204 # fraction
205 qty = Fraction(self.quantity or 0).limit_denominator(MAX_INGREDIENT_DENOMINATOR)
206 if qty.denominator == 1:
207 return str(qty.numerator)
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:
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:
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:
233 if use_plural:
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:
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 ""
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
250 def _format_display(self) -> str:
251 components = []
253 if self.quantity:
254 components.append(self._format_quantity_for_display())
256 if self.quantity and self.unit:
257 components.append(self._format_unit_for_display())
259 if self.food:
260 components.append(self._format_food_for_display())
262 if self.note:
263 components.append(self.note)
265 return " ".join(components).strip()
268class IngredientUnitPagination(PaginationBase):
269 items: list[IngredientUnit]
272class RecipeIngredient(RecipeIngredientBase):
273 title: str | None = None
274 original_text: str | None = None
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)
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
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
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
315class ParsedIngredient(MealieModel):
316 input: str | None = None
317 confidence: IngredientConfidence = IngredientConfidence()
318 ingredient: RecipeIngredient
321class RegisteredParser(str, enum.Enum):
322 nlp = "nlp"
323 brute = "brute"
324 openai = "openai"
327class IngredientsRequest(MealieModel):
328 parser: RegisteredParser = RegisteredParser.nlp
329 ingredients: list[str]
332class IngredientRequest(MealieModel):
333 parser: RegisteredParser = RegisteredParser.nlp
334 ingredient: str
337class MergeFood(MealieModel):
338 from_food: UUID4
339 to_food: UUID4
342class MergeUnit(MealieModel):
343 from_unit: UUID4
344 to_unit: UUID4
347from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelSummary # noqa: E402
349IngredientFood.model_rebuild()