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

212 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-11-25 15:32 +0000

1from __future__ import annotations 1a

2 

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

8 

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

15 

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

22 

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

36 

37app_dirs = get_app_dirs() 1a

38 

39 

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. 

42 

43 Args: 

44 name: The recipe name to create a slug from 

45 max_length: Maximum length for the slug (default: 250) 

46 

47 Returns: 

48 A truncated slug string 

49 

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: 

57 generated_slug = generated_slug[:max_length] 

58 return generated_slug 

59 

60 

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

66 

67 _searchable_properties: ClassVar[list[str]] = ["name"] 1a

68 model_config = ConfigDict(from_attributes=True) 1a

69 

70 

71class RecipeTagPagination(PaginationBase): 1a

72 items: list[RecipeTag] 1a

73 

74 

75class RecipeCategory(RecipeTag): 1a

76 pass 1a

77 

78 

79class RecipeCategoryPagination(PaginationBase): 1a

80 items: list[RecipeCategory] 1a

81 

82 

83class RecipeTool(RecipeTag): 1a

84 id: UUID4 1a

85 households_with_tool: list[str] = [] 1a

86 

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 true1cdefgkhijb

90 return [] 1cdefgkhijb

91 

92 try: 

93 return [household.slug for household in v] 

94 except AttributeError: 

95 return v 

96 

97 

98class RecipeToolPagination(PaginationBase): 1a

99 items: list[RecipeTool] 1a

100 

101 

102class CreateRecipeBulk(BaseModel): 1a

103 url: str 1a

104 categories: list[RecipeCategory] | None = None 1a

105 tags: list[RecipeTag] | None = None 1a

106 

107 

108class CreateRecipeByUrlBulk(BaseModel): 1a

109 imports: list[CreateRecipeBulk] 1a

110 

111 

112class CreateRecipe(MealieModel): 1a

113 name: str 1a

114 

115 

116class RecipeSummary(MealieModel): 1a

117 id: UUID4 | None = None 1a

118 _normalize_search: ClassVar[bool] = True 1a

119 

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

123 

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

130 

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

135 

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

142 

143 date_added: datetime.date | None = None 1a

144 date_updated: datetime.datetime | None = None 1a

145 

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

150 

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: 

154 return val 

155 if isinstance(val, Number): 

156 return str(val) 

157 

158 return val 

159 

160 @property 1a

161 def recipe_yield_display(self) -> str: 1a

162 return f"{self.recipe_yield_quantity} {self.recipe_yield}".strip() 

163 

164 @classmethod 1a

165 def loader_options(cls) -> list[LoaderOption]: 1a

166 return [ 1lcdefb

167 joinedload(RecipeModel.recipe_category), 

168 joinedload(RecipeModel.tags), 

169 joinedload(RecipeModel.tools), 

170 joinedload(RecipeModel.user).load_only(User.household_id), 

171 ] 

172 

173 

174class RecipePagination(PaginationBase): 1a

175 items: list[RecipeSummary] 1a

176 

177 

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

182 

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

188 

189 comments: list[RecipeCommentOut] | None = [] 1a

190 

191 @staticmethod 1a

192 def _get_dir(dir: Path) -> Path: 1a

193 """Gets a directory and creates it if it doesn't exist""" 

194 

195 dir.mkdir(exist_ok=True, parents=True) 

196 return dir 

197 

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

201 

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

205 

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

209 

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

213 

214 @property 1a

215 def directory(self) -> Path: 1a

216 if not self.id: 

217 raise ValueError("Recipe has no ID") 

218 

219 return self.directory_from_id(self.id) 

220 

221 @property 1a

222 def asset_dir(self) -> Path: 1a

223 if not self.id: 

224 raise ValueError("Recipe has no ID") 

225 

226 return self.asset_dir_from_id(self.id) 

227 

228 @property 1a

229 def image_dir(self) -> Path: 1a

230 if not self.id: 

231 raise ValueError("Recipe has no ID") 

232 

233 return self.image_dir_from_id(self.id) 

234 

235 model_config = ConfigDict(from_attributes=True) 1a

236 

237 @field_validator("slug", mode="before") 1a

238 def validate_slug(slug: str, info: ValidationInfo): 1a

239 if not info.data.get("name"): 239 ↛ 242line 239 didn't jump to line 242 because the condition on line 239 was always true

240 return slug 

241 

242 return create_recipe_slug(info.data["name"]) 

243 

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

247 return recipe_ingredient 

248 

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 true

250 return [RecipeIngredient(note=x) for x in recipe_ingredient] 

251 

252 return recipe_ingredient 

253 

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 true

257 return [RecipeTag(id=uuid4(), name=c, slug=slugify(c)) for c in cats] 

258 return cats 

259 

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

263 return [RecipeCategory(id=uuid4(), name=c, slug=slugify(c)) for c in cats] 

264 return cats 

265 

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 true

269 return uuid4() 

270 return group_id 

271 

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 true

275 return uuid4() 

276 return household_id 

277 

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 true

281 return uuid4() 

282 return user_id 

283 

284 @field_validator("extras", mode="before") 1a

285 def convert_extras_to_dict(cls, v): 1a

286 if isinstance(v, dict): 

287 return v 

288 

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

290 

291 @field_validator("nutrition", mode="before") 1a

292 def validate_nutrition(cls, v): 1a

293 return v or None 

294 

295 @classmethod 1a

296 def loader_options(cls) -> list[LoaderOption]: 1a

297 return [ 1lcdefghijb

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 ] 

317 

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

328 

329 if search_type is SearchType.fuzzy: 

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 ) 

345 

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 ) 

358 

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 ) 

372 

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

380 

381 

382class RecipeLastMade(BaseModel): 1a

383 timestamp: datetime.datetime 1a

384 

385 

386from mealie.schema.recipe.recipe_ingredient import RecipeIngredient # noqa: E402 1a

387 

388RecipeSummary.model_rebuild() 1a

389Recipe.model_rebuild() 1a