Coverage for opt/mealie/lib/python3.12/site-packages/mealie/schema/recipe/recipe.py: 94%
212 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
4from numbers import Number
5from pathlib import Path
6from typing import Annotated, Any, ClassVar
7from uuid import uuid4
9from pydantic import UUID4, BaseModel, ConfigDict, Field, field_validator
10from pydantic_core.core_schema import ValidationInfo
11from slugify import slugify
12from sqlalchemy import Select, desc, func, or_, select, text
13from sqlalchemy.orm import Session, joinedload, selectinload
14from sqlalchemy.orm.interfaces import LoaderOption
16from mealie.core.config import get_app_dirs
17from mealie.core.exceptions import SlugError
18from mealie.db.models.users.users import User
19from mealie.schema._mealie import MealieModel, SearchType
20from mealie.schema._mealie.mealie_model import UpdatedAtField
21from mealie.schema.response.pagination import PaginationBase
23from ...db.models.recipe import (
24 IngredientFoodModel,
25 RecipeComment,
26 RecipeIngredientModel,
27 RecipeInstruction,
28 RecipeModel,
29)
30from .recipe_asset import RecipeAsset
31from .recipe_comments import RecipeCommentOut
32from .recipe_notes import RecipeNote
33from .recipe_nutrition import Nutrition
34from .recipe_settings import RecipeSettings
35from .recipe_step import RecipeStep
37app_dirs = get_app_dirs()
40def create_recipe_slug(name: str, max_length: int = 250) -> str:
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)
54 if not generated_slug:
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 true
57 generated_slug = generated_slug[:max_length]
58 return generated_slug
61class RecipeTag(MealieModel):
62 id: UUID4 | None = None
63 group_id: UUID4 | None = None
64 name: str
65 slug: str
67 _searchable_properties: ClassVar[list[str]] = ["name"]
68 model_config = ConfigDict(from_attributes=True)
71class RecipeTagPagination(PaginationBase):
72 items: list[RecipeTag]
75class RecipeCategory(RecipeTag):
76 pass
79class RecipeCategoryPagination(PaginationBase):
80 items: list[RecipeCategory]
83class RecipeTool(RecipeTag):
84 id: UUID4
85 households_with_tool: list[str] = []
87 @field_validator("households_with_tool", mode="before")
88 def convert_households_to_slugs(cls, v):
89 if not v:
90 return []
92 try:
93 return [household.slug for household in v]
94 except AttributeError:
95 return v
98class RecipeToolPagination(PaginationBase):
99 items: list[RecipeTool]
102class CreateRecipeBulk(BaseModel):
103 url: str
104 categories: list[RecipeCategory] | None = None
105 tags: list[RecipeTag] | None = None
108class CreateRecipeByUrlBulk(BaseModel):
109 imports: list[CreateRecipeBulk]
112class CreateRecipe(MealieModel):
113 name: str
116class RecipeSummary(MealieModel):
117 id: UUID4 | None = None
118 _normalize_search: ClassVar[bool] = True
120 user_id: UUID4 = Field(default_factory=uuid4, validate_default=True)
121 household_id: UUID4 = Field(default_factory=uuid4, validate_default=True)
122 group_id: UUID4 = Field(default_factory=uuid4, validate_default=True)
124 name: str | None = None
125 slug: Annotated[str, Field(validate_default=True)] = ""
126 image: Any | None = None
127 recipe_servings: float = 0
128 recipe_yield_quantity: float = 0
129 recipe_yield: str | None = None
131 total_time: str | None = None
132 prep_time: str | None = None
133 cook_time: str | None = None
134 perform_time: str | None = None
136 description: str | None = ""
137 recipe_category: Annotated[list[RecipeCategory] | None, Field(validate_default=True)] | None = []
138 tags: Annotated[list[RecipeTag] | None, Field(validate_default=True)] = []
139 tools: list[RecipeTool] = []
140 rating: float | None = None
141 org_url: str | None = Field(None, alias="orgURL")
143 date_added: datetime.date | None = None
144 date_updated: datetime.datetime | None = None
146 created_at: datetime.datetime | None = None
147 updated_at: datetime.datetime | None = UpdatedAtField(None)
148 last_made: datetime.datetime | None = None
149 model_config = ConfigDict(from_attributes=True)
151 @field_validator("recipe_yield", "total_time", "prep_time", "cook_time", "perform_time", mode="before")
152 def clean_strings(val: Any):
153 if val is None:
154 return val
155 if isinstance(val, Number):
156 return str(val)
158 return val
160 @property
161 def recipe_yield_display(self) -> str:
162 return f"{self.recipe_yield_quantity} {self.recipe_yield}".strip()
164 @classmethod
165 def loader_options(cls) -> list[LoaderOption]:
166 return [
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):
175 items: list[RecipeSummary]
178class Recipe(RecipeSummary):
179 recipe_ingredient: Annotated[list[RecipeIngredient], Field(validate_default=True)] = []
180 recipe_instructions: list[RecipeStep] | None = []
181 nutrition: Nutrition | None = None
183 # Mealie Specific
184 settings: RecipeSettings | None = None
185 assets: list[RecipeAsset] | None = []
186 notes: list[RecipeNote] | None = []
187 extras: dict | None = {}
189 comments: list[RecipeCommentOut] | None = []
191 @staticmethod
192 def _get_dir(dir: Path) -> Path:
193 """Gets a directory and creates it if it doesn't exist"""
195 dir.mkdir(exist_ok=True, parents=True)
196 return dir
198 @classmethod
199 def directory_from_id(cls, recipe_id: UUID4 | str) -> Path:
200 return cls._get_dir(app_dirs.RECIPE_DATA_DIR.joinpath(str(recipe_id)))
202 @classmethod
203 def asset_dir_from_id(cls, recipe_id: UUID4 | str) -> Path:
204 return cls._get_dir(cls.directory_from_id(recipe_id).joinpath("assets"))
206 @classmethod
207 def image_dir_from_id(cls, recipe_id: UUID4 | str) -> Path:
208 return cls._get_dir(cls.directory_from_id(recipe_id).joinpath("images"))
210 @classmethod
211 def timeline_image_dir_from_id(cls, recipe_id: UUID4 | str, timeline_event_id: UUID4 | str) -> Path:
212 return cls._get_dir(cls.image_dir_from_id(recipe_id).joinpath("timeline").joinpath(str(timeline_event_id)))
214 @property
215 def directory(self) -> Path:
216 if not self.id: 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true
217 raise ValueError("Recipe has no ID")
219 return self.directory_from_id(self.id)
221 @property
222 def asset_dir(self) -> Path:
223 if not self.id: 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true
224 raise ValueError("Recipe has no ID")
226 return self.asset_dir_from_id(self.id)
228 @property
229 def image_dir(self) -> Path:
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)
237 @field_validator("slug", mode="before")
238 def validate_slug(slug: str, info: ValidationInfo):
239 if not info.data.get("name"):
240 return slug
242 return create_recipe_slug(info.data["name"])
244 @field_validator("recipe_ingredient", mode="before")
245 def validate_ingredients(recipe_ingredient):
246 if not recipe_ingredient or not isinstance(recipe_ingredient, list):
247 return recipe_ingredient
249 if all(isinstance(elem, str) for elem in recipe_ingredient):
250 return [RecipeIngredient(note=x) for x in recipe_ingredient]
252 return recipe_ingredient
254 @field_validator("tags", mode="before")
255 def validate_tags(cats: list[Any]):
256 if isinstance(cats, list) and cats and isinstance(cats[0], str):
257 return [RecipeTag(id=uuid4(), name=c, slug=slugify(c)) for c in cats]
258 return cats
260 @field_validator("recipe_category", mode="before")
261 def validate_categories(cats: list[Any]):
262 if isinstance(cats, list) and cats and isinstance(cats[0], str):
263 return [RecipeCategory(id=uuid4(), name=c, slug=slugify(c)) for c in cats]
264 return cats
266 @field_validator("group_id", mode="before")
267 def validate_group_id(group_id: Any):
268 if isinstance(group_id, int):
269 return uuid4()
270 return group_id
272 @field_validator("household_id", mode="before")
273 def validate_household_id(household_id: Any):
274 if isinstance(household_id, int):
275 return uuid4()
276 return household_id
278 @field_validator("user_id", mode="before")
279 def validate_user_id(user_id: Any):
280 if isinstance(user_id, int):
281 return uuid4()
282 return user_id
284 @field_validator("extras", mode="before")
285 def convert_extras_to_dict(cls, v):
286 if isinstance(v, dict):
287 return v
289 return {x.key_name: x.value for x in v} if v else {}
291 @field_validator("nutrition", mode="before")
292 def validate_nutrition(cls, v):
293 return v or None
295 @classmethod
296 def loader_options(cls) -> list[LoaderOption]:
297 return [
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
319 def filter_search_query(
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 true
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 = (
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(
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):
383 timestamp: datetime.datetime
386from mealie.schema.recipe.recipe_ingredient import RecipeIngredient # noqa: E402
388RecipeSummary.model_rebuild()
389Recipe.model_rebuild()