Coverage for opt/mealie/lib/python3.12/site-packages/mealie/schema/recipe/recipe.py: 85%
212 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-11-25 15:48 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-11-25 15:48 +0000
1from __future__ import annotations 1a
3import datetime 1a
4from numbers import Number 1a
5from pathlib import Path 1a
6from typing import Annotated, Any, ClassVar 1a
7from uuid import uuid4 1a
9from pydantic import UUID4, BaseModel, ConfigDict, Field, field_validator 1a
10from pydantic_core.core_schema import ValidationInfo 1a
11from slugify import slugify 1a
12from sqlalchemy import Select, desc, func, or_, select, text 1a
13from sqlalchemy.orm import Session, joinedload, selectinload 1a
14from sqlalchemy.orm.interfaces import LoaderOption 1a
16from mealie.core.config import get_app_dirs 1a
17from mealie.core.exceptions import SlugError 1a
18from mealie.db.models.users.users import User 1a
19from mealie.schema._mealie import MealieModel, SearchType 1a
20from mealie.schema._mealie.mealie_model import UpdatedAtField 1a
21from mealie.schema.response.pagination import PaginationBase 1a
23from ...db.models.recipe import ( 1a
24 IngredientFoodModel,
25 RecipeComment,
26 RecipeIngredientModel,
27 RecipeInstruction,
28 RecipeModel,
29)
30from .recipe_asset import RecipeAsset 1a
31from .recipe_comments import RecipeCommentOut 1a
32from .recipe_notes import RecipeNote 1a
33from .recipe_nutrition import Nutrition 1a
34from .recipe_settings import RecipeSettings 1a
35from .recipe_step import RecipeStep 1a
37app_dirs = get_app_dirs() 1a
40def create_recipe_slug(name: str, max_length: int = 250) -> str: 1a
41 """Generate a slug from a recipe name, truncating to a reasonable length.
43 Args:
44 name: The recipe name to create a slug from
45 max_length: Maximum length for the slug (default: 250)
47 Returns:
48 A truncated slug string
50 Raises:
51 ValueError: If the name cannot be converted to a valid slug
52 """
53 generated_slug = slugify(name) 1dcefb
54 if not generated_slug: 1dcefb
55 raise SlugError("Recipe name cannot be empty or contain only special characters")
56 if len(generated_slug) > max_length: 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true1dcefb
57 generated_slug = generated_slug[:max_length]
58 return generated_slug 1dcefb
61class RecipeTag(MealieModel): 1a
62 id: UUID4 | None = None 1a
63 group_id: UUID4 | None = None 1a
64 name: str 1a
65 slug: str 1a
67 _searchable_properties: ClassVar[list[str]] = ["name"] 1a
68 model_config = ConfigDict(from_attributes=True) 1a
71class RecipeTagPagination(PaginationBase): 1a
72 items: list[RecipeTag] 1a
75class RecipeCategory(RecipeTag): 1a
76 pass 1a
79class RecipeCategoryPagination(PaginationBase): 1a
80 items: list[RecipeCategory] 1a
83class RecipeTool(RecipeTag): 1a
84 id: UUID4 1a
85 households_with_tool: list[str] = [] 1a
87 @field_validator("households_with_tool", mode="before") 1a
88 def convert_households_to_slugs(cls, v): 1a
89 if not v: 89 ↛ 92line 89 didn't jump to line 92 because the condition on line 89 was always true1ifjklmnoptqrsb
90 return [] 1ifjklmnoptqrsb
92 try:
93 return [household.slug for household in v]
94 except AttributeError:
95 return v
98class RecipeToolPagination(PaginationBase): 1a
99 items: list[RecipeTool] 1a
102class CreateRecipeBulk(BaseModel): 1a
103 url: str 1a
104 categories: list[RecipeCategory] | None = None 1a
105 tags: list[RecipeTag] | None = None 1a
108class CreateRecipeByUrlBulk(BaseModel): 1a
109 imports: list[CreateRecipeBulk] 1a
112class CreateRecipe(MealieModel): 1a
113 name: str 1a
116class RecipeSummary(MealieModel): 1a
117 id: UUID4 | None = None 1a
118 _normalize_search: ClassVar[bool] = True 1a
120 user_id: UUID4 = Field(default_factory=uuid4, validate_default=True) 1a
121 household_id: UUID4 = Field(default_factory=uuid4, validate_default=True) 1a
122 group_id: UUID4 = Field(default_factory=uuid4, validate_default=True) 1a
124 name: str | None = None 1a
125 slug: Annotated[str, Field(validate_default=True)] = "" 1a
126 image: Any | None = None 1a
127 recipe_servings: float = 0 1a
128 recipe_yield_quantity: float = 0 1a
129 recipe_yield: str | None = None 1a
131 total_time: str | None = None 1a
132 prep_time: str | None = None 1a
133 cook_time: str | None = None 1a
134 perform_time: str | None = None 1a
136 description: str | None = "" 1a
137 recipe_category: Annotated[list[RecipeCategory] | None, Field(validate_default=True)] | None = [] 1a
138 tags: Annotated[list[RecipeTag] | None, Field(validate_default=True)] = [] 1a
139 tools: list[RecipeTool] = [] 1a
140 rating: float | None = None 1a
141 org_url: str | None = Field(None, alias="orgURL") 1a
143 date_added: datetime.date | None = None 1a
144 date_updated: datetime.datetime | None = None 1a
146 created_at: datetime.datetime | None = None 1a
147 updated_at: datetime.datetime | None = UpdatedAtField(None) 1a
148 last_made: datetime.datetime | None = None 1a
149 model_config = ConfigDict(from_attributes=True) 1a
151 @field_validator("recipe_yield", "total_time", "prep_time", "cook_time", "perform_time", mode="before") 1a
152 def clean_strings(val: Any): 1a
153 if val is None: 1dcefb
154 return val 1dcefb
155 if isinstance(val, Number): 155 ↛ 156line 155 didn't jump to line 156 because the condition on line 155 was never true1db
156 return str(val)
158 return val 1db
160 @property 1a
161 def recipe_yield_display(self) -> str: 1a
162 return f"{self.recipe_yield_quantity} {self.recipe_yield}".strip()
164 @classmethod 1a
165 def loader_options(cls) -> list[LoaderOption]: 1a
166 return [ 1hb
167 joinedload(RecipeModel.recipe_category),
168 joinedload(RecipeModel.tags),
169 joinedload(RecipeModel.tools),
170 joinedload(RecipeModel.user).load_only(User.household_id),
171 ]
174class RecipePagination(PaginationBase): 1a
175 items: list[RecipeSummary] 1a
178class Recipe(RecipeSummary): 1a
179 recipe_ingredient: Annotated[list[RecipeIngredient], Field(validate_default=True)] = [] 1a
180 recipe_instructions: list[RecipeStep] | None = [] 1a
181 nutrition: Nutrition | None = None 1a
183 # Mealie Specific
184 settings: RecipeSettings | None = None 1a
185 assets: list[RecipeAsset] | None = [] 1a
186 notes: list[RecipeNote] | None = [] 1a
187 extras: dict | None = {} 1a
189 comments: list[RecipeCommentOut] | None = [] 1a
191 @staticmethod 1a
192 def _get_dir(dir: Path) -> Path: 1a
193 """Gets a directory and creates it if it doesn't exist"""
195 dir.mkdir(exist_ok=True, parents=True) 1cegb
196 return dir 1cegb
198 @classmethod 1a
199 def directory_from_id(cls, recipe_id: UUID4 | str) -> Path: 1a
200 return cls._get_dir(app_dirs.RECIPE_DATA_DIR.joinpath(str(recipe_id))) 1cegb
202 @classmethod 1a
203 def asset_dir_from_id(cls, recipe_id: UUID4 | str) -> Path: 1a
204 return cls._get_dir(cls.directory_from_id(recipe_id).joinpath("assets")) 1cb
206 @classmethod 1a
207 def image_dir_from_id(cls, recipe_id: UUID4 | str) -> Path: 1a
208 return cls._get_dir(cls.directory_from_id(recipe_id).joinpath("images")) 1g
210 @classmethod 1a
211 def timeline_image_dir_from_id(cls, recipe_id: UUID4 | str, timeline_event_id: UUID4 | str) -> Path: 1a
212 return cls._get_dir(cls.image_dir_from_id(recipe_id).joinpath("timeline").joinpath(str(timeline_event_id))) 1g
214 @property 1a
215 def directory(self) -> Path: 1a
216 if not self.id:
217 raise ValueError("Recipe has no ID")
219 return self.directory_from_id(self.id)
221 @property 1a
222 def asset_dir(self) -> Path: 1a
223 if not self.id: 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true1cb
224 raise ValueError("Recipe has no ID")
226 return self.asset_dir_from_id(self.id) 1cb
228 @property 1a
229 def image_dir(self) -> Path: 1a
230 if not self.id:
231 raise ValueError("Recipe has no ID")
233 return self.image_dir_from_id(self.id)
235 model_config = ConfigDict(from_attributes=True) 1a
237 @field_validator("slug", mode="before") 1a
238 def validate_slug(slug: str, info: ValidationInfo): 1a
239 if not info.data.get("name"): 1dcefb
240 return slug 1db
242 return create_recipe_slug(info.data["name"]) 1dcefb
244 @field_validator("recipe_ingredient", mode="before") 1a
245 def validate_ingredients(recipe_ingredient): 1a
246 if not recipe_ingredient or not isinstance(recipe_ingredient, list): 1dcefb
247 return recipe_ingredient 1db
249 if all(isinstance(elem, str) for elem in recipe_ingredient): 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true1dcefb
250 return [RecipeIngredient(note=x) for x in recipe_ingredient]
252 return recipe_ingredient 1dcefb
254 @field_validator("tags", mode="before") 1a
255 def validate_tags(cats: list[Any]): 1a
256 if isinstance(cats, list) and cats and isinstance(cats[0], str): 256 ↛ 257line 256 didn't jump to line 257 because the condition on line 256 was never true1dcefb
257 return [RecipeTag(id=uuid4(), name=c, slug=slugify(c)) for c in cats]
258 return cats 1dcefb
260 @field_validator("recipe_category", mode="before") 1a
261 def validate_categories(cats: list[Any]): 1a
262 if isinstance(cats, list) and cats and isinstance(cats[0], str): 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true1dcefb
263 return [RecipeCategory(id=uuid4(), name=c, slug=slugify(c)) for c in cats]
264 return cats 1dcefb
266 @field_validator("group_id", mode="before") 1a
267 def validate_group_id(group_id: Any): 1a
268 if isinstance(group_id, int): 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true1dcefb
269 return uuid4()
270 return group_id 1dcefb
272 @field_validator("household_id", mode="before") 1a
273 def validate_household_id(household_id: Any): 1a
274 if isinstance(household_id, int): 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true1dcefb
275 return uuid4()
276 return household_id 1dcefb
278 @field_validator("user_id", mode="before") 1a
279 def validate_user_id(user_id: Any): 1a
280 if isinstance(user_id, int): 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true1dcefb
281 return uuid4()
282 return user_id 1dcefb
284 @field_validator("extras", mode="before") 1a
285 def convert_extras_to_dict(cls, v): 1a
286 if isinstance(v, dict): 1dcefb
287 return v 1db
289 return {x.key_name: x.value for x in v} if v else {} 1cefb
291 @field_validator("nutrition", mode="before") 1a
292 def validate_nutrition(cls, v): 1a
293 return v or None 1cefb
295 @classmethod 1a
296 def loader_options(cls) -> list[LoaderOption]: 1a
297 return [ 1dceifjklmnopqrsb
298 selectinload(RecipeModel.assets),
299 selectinload(RecipeModel.comments).joinedload(RecipeComment.user),
300 selectinload(RecipeModel.extras),
301 joinedload(RecipeModel.recipe_category),
302 selectinload(RecipeModel.tags),
303 selectinload(RecipeModel.tools),
304 selectinload(RecipeModel.recipe_ingredient).joinedload(RecipeIngredientModel.unit),
305 selectinload(RecipeModel.recipe_ingredient)
306 .joinedload(RecipeIngredientModel.food)
307 .joinedload(IngredientFoodModel.extras),
308 selectinload(RecipeModel.recipe_ingredient)
309 .joinedload(RecipeIngredientModel.food)
310 .joinedload(IngredientFoodModel.label),
311 selectinload(RecipeModel.recipe_instructions).joinedload(RecipeInstruction.ingredient_references),
312 joinedload(RecipeModel.nutrition),
313 joinedload(RecipeModel.settings),
314 # for whatever reason, joinedload can mess up the order here, so use selectinload just this once
315 selectinload(RecipeModel.notes),
316 ]
318 @classmethod 1a
319 def filter_search_query( 1a
320 cls, db_model, query: Select, session: Session, search_type: SearchType, search: str, search_list: list[str]
321 ) -> Select:
322 """
323 1. token search looks for any individual exact hit in name, description, and ingredients
324 2. fuzzy search looks for trigram hits in name, description, and ingredients
325 3. Sort order is determined by closeness to the recipe name
326 Should search also look at tags?
327 """
329 if search_type is SearchType.fuzzy: 329 ↛ 333line 329 didn't jump to line 333 because the condition on line 329 was never true1h
330 # I would prefer to just do this in the recipe_ingredient.any part of the main query,
331 # but it turns out that at least sqlite wont use indexes for that correctly anymore and
332 # takes a big hit, so prefiltering it is
333 ingredient_ids = (
334 session.execute(
335 select(RecipeIngredientModel.id).filter(
336 or_(
337 RecipeIngredientModel.note_normalized.op("%>")(search),
338 RecipeIngredientModel.original_text_normalized.op("%>")(search),
339 )
340 )
341 )
342 .scalars()
343 .all()
344 )
346 session.execute(text(f"set pg_trgm.word_similarity_threshold = {cls._fuzzy_similarity_threshold};"))
347 return query.filter(
348 or_(
349 RecipeModel.name_normalized.op("%>")(search),
350 RecipeModel.description_normalized.op("%>")(search),
351 RecipeModel.recipe_ingredient.any(RecipeIngredientModel.id.in_(ingredient_ids)),
352 )
353 ).order_by( # trigram ordering could be too slow on million record db, but is fine with thousands.
354 func.least(
355 RecipeModel.name_normalized.op("<->>")(search),
356 )
357 )
359 else:
360 ingredient_ids = ( 1h
361 session.execute(
362 select(RecipeIngredientModel.id).filter(
363 or_(
364 *[RecipeIngredientModel.note_normalized.like(f"%{ns}%") for ns in search_list],
365 *[RecipeIngredientModel.original_text_normalized.like(f"%{ns}%") for ns in search_list],
366 )
367 )
368 )
369 .scalars()
370 .all()
371 )
373 return query.filter( 1h
374 or_(
375 *[RecipeModel.name_normalized.like(f"%{ns}%") for ns in search_list],
376 *[RecipeModel.description_normalized.like(f"%{ns}%") for ns in search_list],
377 RecipeModel.recipe_ingredient.any(RecipeIngredientModel.id.in_(ingredient_ids)),
378 )
379 ).order_by(desc(RecipeModel.name_normalized.like(f"%{search}%")))
382class RecipeLastMade(BaseModel): 1a
383 timestamp: datetime.datetime 1a
386from mealie.schema.recipe.recipe_ingredient import RecipeIngredient # noqa: E402 1a
388RecipeSummary.model_rebuild() 1a
389Recipe.model_rebuild() 1a