Coverage for opt/mealie/lib/python3.12/site-packages/mealie/db/models/recipe/recipe.py: 62%

130 statements  

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

1from datetime import UTC, date, datetime 1a

2from typing import TYPE_CHECKING 1a

3 

4import sqlalchemy as sa 1a

5import sqlalchemy.orm as orm 1a

6from pydantic import ConfigDict 1a

7from sqlalchemy import event 1a

8from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy 1a

9from sqlalchemy.ext.orderinglist import ordering_list 1a

10from sqlalchemy.orm import Mapped, mapped_column, validates 1a

11from sqlalchemy.orm.attributes import get_history 1a

12from sqlalchemy.orm.session import object_session 1a

13 

14from mealie.db.models._model_utils.auto_init import auto_init 1a

15from mealie.db.models._model_utils.datetime import NaiveDateTime, get_utc_today 1a

16from mealie.db.models._model_utils.guid import GUID 1a

17 

18from .._model_base import BaseMixins, SqlAlchemyBase 1a

19from ..household.household_to_recipe import HouseholdToRecipe 1a

20from ..users.user_to_recipe import UserToRecipe 1a

21from .api_extras import ApiExtras, api_extras 1a

22from .assets import RecipeAsset 1a

23from .category import recipes_to_categories 1a

24from .comment import RecipeComment 1a

25from .ingredient import RecipeIngredientModel 1a

26from .instruction import RecipeInstruction 1a

27from .note import Note 1a

28from .nutrition import Nutrition 1a

29from .recipe_timeline import RecipeTimelineEvent 1a

30from .settings import RecipeSettings 1a

31from .shared import RecipeShareTokenModel 1a

32from .tag import recipes_to_tags 1a

33from .tool import recipes_to_tools 1a

34 

35if TYPE_CHECKING: 35 ↛ 36line 35 didn't jump to line 36 because the condition on line 35 was never true1a

36 from ..group import Group, GroupMealPlan 

37 from ..household import Household, ShoppingListItemRecipeReference, ShoppingListRecipeReference 

38 from ..users import User 

39 from . import Category, Tag, Tool 

40 

41 

42class RecipeModel(SqlAlchemyBase, BaseMixins): 1a

43 __tablename__ = "recipes" 1a

44 __table_args__: tuple[sa.UniqueConstraint, ...] = ( 1a

45 sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"), 

46 ) 

47 

48 id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate) 1a

49 slug: Mapped[str | None] = mapped_column(sa.String, index=True) 1a

50 

51 # ID Relationships 

52 group_id: Mapped[GUID] = mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True) 1a

53 group: Mapped["Group"] = orm.relationship("Group", back_populates="recipes", foreign_keys=[group_id]) 1a

54 

55 household_id: AssociationProxy[GUID] = association_proxy("user", "household_id") 1a

56 household: AssociationProxy["Household"] = association_proxy("user", "household") 1a

57 

58 user_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("users.id", use_alter=True), index=True) 1a

59 user: Mapped["User"] = orm.relationship("User", uselist=False, foreign_keys=[user_id]) 1a

60 

61 rating: Mapped[float | None] = mapped_column(sa.Float, index=True, nullable=True) 1a

62 rated_by: Mapped[list["User"]] = orm.relationship( 1a

63 "User", 

64 secondary=UserToRecipe.__tablename__, 

65 back_populates="rated_recipes", 

66 overlaps="recipe,favorited_by,favorited_recipes", 

67 ) 

68 favorited_by: Mapped[list["User"]] = orm.relationship( 1a

69 "User", 

70 secondary=UserToRecipe.__tablename__, 

71 primaryjoin="and_(RecipeModel.id==UserToRecipe.recipe_id, UserToRecipe.is_favorite==True)", 

72 back_populates="favorite_recipes", 

73 overlaps="recipe,rated_by,rated_recipes", 

74 ) 

75 

76 meal_entries: Mapped[list["GroupMealPlan"]] = orm.relationship( 1a

77 "GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan" 

78 ) 

79 

80 # General Recipe Properties 

81 name: Mapped[str] = mapped_column(sa.String, nullable=False) 1a

82 description: Mapped[str | None] = mapped_column(sa.String) 1a

83 

84 image: Mapped[str | None] = mapped_column(sa.String) 1a

85 

86 # Time Related Properties 

87 total_time: Mapped[str | None] = mapped_column(sa.String) 1a

88 prep_time: Mapped[str | None] = mapped_column(sa.String) 1a

89 perform_time: Mapped[str | None] = mapped_column(sa.String) 1a

90 cook_time: Mapped[str | None] = mapped_column(sa.String) 1a

91 

92 recipe_yield: Mapped[str | None] = mapped_column(sa.String) 1a

93 recipe_yield_quantity: Mapped[float] = mapped_column(sa.Float, index=True, default=0) 1a

94 recipe_servings: Mapped[float] = mapped_column(sa.Float, index=True, default=0) 1a

95 

96 assets: Mapped[list[RecipeAsset]] = orm.relationship("RecipeAsset", cascade="all, delete-orphan") 1a

97 nutrition: Mapped[Nutrition] = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan") 1a

98 recipe_category: Mapped[list["Category"]] = orm.relationship( 1a

99 "Category", secondary=recipes_to_categories, back_populates="recipes" 

100 ) 

101 tools: Mapped[list["Tool"]] = orm.relationship("Tool", secondary=recipes_to_tools, back_populates="recipes") 1a

102 

103 recipe_ingredient: Mapped[list[RecipeIngredientModel]] = orm.relationship( 1a

104 "RecipeIngredientModel", 

105 cascade="all, delete-orphan", 

106 order_by="RecipeIngredientModel.position", 

107 collection_class=ordering_list("position"), 

108 ) 

109 recipe_instructions: Mapped[list[RecipeInstruction]] = orm.relationship( 1a

110 "RecipeInstruction", 

111 cascade="all, delete-orphan", 

112 order_by="RecipeInstruction.position", 

113 collection_class=ordering_list("position"), 

114 ) 

115 

116 share_tokens: Mapped[list[RecipeShareTokenModel]] = orm.relationship( 1a

117 RecipeShareTokenModel, back_populates="recipe", cascade="all, delete, delete-orphan" 

118 ) 

119 

120 comments: Mapped[list[RecipeComment]] = orm.relationship( 1a

121 "RecipeComment", back_populates="recipe", cascade="all, delete, delete-orphan" 

122 ) 

123 

124 timeline_events: Mapped[list[RecipeTimelineEvent]] = orm.relationship( 1a

125 "RecipeTimelineEvent", back_populates="recipe", cascade="all, delete, delete-orphan" 

126 ) 

127 

128 # Mealie Specific 

129 settings: Mapped[list["RecipeSettings"]] = orm.relationship( 1a

130 "RecipeSettings", uselist=False, cascade="all, delete-orphan" 

131 ) 

132 tags: Mapped[list["Tag"]] = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes") 1a

133 notes: Mapped[list[Note]] = orm.relationship("Note", cascade="all, delete-orphan") 1a

134 org_url: Mapped[str | None] = mapped_column(sa.String) 1a

135 extras: Mapped[list[ApiExtras]] = orm.relationship("ApiExtras", cascade="all, delete-orphan") 1a

136 

137 # Time Stamp Properties 

138 date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today) 1a

139 date_updated: Mapped[datetime | None] = mapped_column(NaiveDateTime) 1a

140 

141 last_made: Mapped[datetime | None] = mapped_column(NaiveDateTime) 1a

142 made_by: Mapped[list["Household"]] = orm.relationship( 1a

143 "Household", secondary=HouseholdToRecipe.__tablename__, back_populates="made_recipes" 

144 ) 

145 

146 # Shopping List Refs 

147 shopping_list_refs: Mapped[list["ShoppingListRecipeReference"]] = orm.relationship( 1a

148 "ShoppingListRecipeReference", 

149 back_populates="recipe", 

150 cascade="all, delete-orphan", 

151 ) 

152 shopping_list_item_refs: Mapped[list["ShoppingListItemRecipeReference"]] = orm.relationship( 1a

153 "ShoppingListItemRecipeReference", 

154 back_populates="recipe", 

155 cascade="all, delete-orphan", 

156 ) 

157 

158 # Automatically updated by sqlalchemy event, do not write to this manually 

159 name_normalized: Mapped[str] = mapped_column(sa.String, nullable=False, index=True) 1a

160 description_normalized: Mapped[str | None] = mapped_column(sa.String, index=True) 1a

161 model_config = ConfigDict( 1a

162 get_attr="slug", 

163 exclude={ 

164 "assets", 

165 "notes", 

166 "nutrition", 

167 "recipe_ingredient", 

168 "recipe_instructions", 

169 "settings", 

170 "comments", 

171 "timeline_events", 

172 }, 

173 ) 

174 

175 # Deprecated 

176 recipeCuisine: Mapped[str | None] = mapped_column(sa.String) 1a

177 is_ocr_recipe: Mapped[bool | None] = mapped_column(sa.Boolean, default=False) 1a

178 

179 @validates("name") 1a

180 def validate_name(self, _, name): 1a

181 assert name != "" 

182 return name 

183 

184 @api_extras 1a

185 @auto_init() 1a

186 def __init__( 1a

187 self, 

188 session, 

189 name: str | None = None, 

190 description: str | None = None, 

191 assets: list | None = None, 

192 notes: list[dict] | None = None, 

193 nutrition: dict | None = None, 

194 recipe_ingredient: list[dict] | None = None, 

195 recipe_instructions: list[dict] | None = None, 

196 settings: dict | None = None, 

197 **_, 

198 ) -> None: 

199 self.nutrition = Nutrition(**(nutrition or {})) 

200 

201 if recipe_instructions is not None: 

202 self.recipe_instructions = [RecipeInstruction(**step, session=session) for step in recipe_instructions] 

203 

204 if recipe_ingredient is not None: 

205 self.recipe_ingredient = [RecipeIngredientModel(**ingr, session=session) for ingr in recipe_ingredient] 

206 

207 if assets: 

208 self.assets = [RecipeAsset(**a) for a in assets] 

209 

210 self.settings = RecipeSettings(**(settings or {})) 

211 

212 if notes: 

213 self.notes = [Note(**n) for n in notes] 

214 

215 self.date_updated = datetime.now(UTC) 

216 

217 # SQLAlchemy events do not seem to register things that are set during auto_init 

218 if name is not None: 

219 self.name_normalized = self.normalize(name) 

220 

221 if description is not None: 

222 self.description_normalized = self.normalize(description) 

223 

224 tableargs = [ # base set of indices 

225 sa.UniqueConstraint("slug", "group_id", name="recipe_slug_group_id_key"), 

226 sa.Index( 

227 "ix_recipes_name_normalized", 

228 "name_normalized", 

229 unique=False, 

230 ), 

231 sa.Index( 

232 "ix_recipes_description_normalized", 

233 "description_normalized", 

234 unique=False, 

235 ), 

236 ] 

237 

238 if session.get_bind().name == "postgresql": 

239 tableargs.extend( 

240 [ 

241 sa.Index( 

242 "ix_recipes_name_normalized_gin", 

243 "name_normalized", 

244 unique=False, 

245 postgresql_using="gin", 

246 postgresql_ops={ 

247 "name_normalized": "gin_trgm_ops", 

248 }, 

249 ), 

250 sa.Index( 

251 "ix_recipes_description_normalized_gin", 

252 "description_normalized", 

253 unique=False, 

254 postgresql_using="gin", 

255 postgresql_ops={ 

256 "description_normalized": "gin_trgm_ops", 

257 }, 

258 ), 

259 ] 

260 ) 

261 # add indices 

262 self.__table_args__ = tuple(tableargs) 

263 

264 

265@event.listens_for(RecipeModel.name, "set") 1a

266def receive_name(target: RecipeModel, value: str, oldvalue, initiator): 1a

267 target.name_normalized = RecipeModel.normalize(value) 

268 

269 

270@event.listens_for(RecipeModel.description, "set") 1a

271def receive_description(target: RecipeModel, value: str, oldvalue, initiator): 1a

272 if value is not None: 

273 target.description_normalized = RecipeModel.normalize(value) 

274 else: 

275 target.description_normalized = None 

276 

277 

278@event.listens_for(RecipeModel, "before_update") 1a

279def calculate_rating(mapper, connection, target: RecipeModel): 1a

280 session = object_session(target) 

281 if not (session and session.is_modified(target, "rating")): 

282 return 

283 

284 history = get_history(target, "rating") 

285 old_value = history.deleted[0] if history.deleted else None 

286 new_value = history.added[0] if history.added else None 

287 if old_value == new_value: 

288 return 

289 

290 target.rating = ( 

291 session.query(sa.func.avg(UserToRecipe.rating)) 

292 .filter(UserToRecipe.recipe_id == target.id, UserToRecipe.rating is not None, UserToRecipe.rating > 0) 

293 .scalar() 

294 )