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
« 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
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
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
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
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
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 )
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
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
55 household_id: AssociationProxy[GUID] = association_proxy("user", "household_id") 1a
56 household: AssociationProxy["Household"] = association_proxy("user", "household") 1a
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
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 )
76 meal_entries: Mapped[list["GroupMealPlan"]] = orm.relationship( 1a
77 "GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan"
78 )
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
84 image: Mapped[str | None] = mapped_column(sa.String) 1a
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
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
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
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 )
116 share_tokens: Mapped[list[RecipeShareTokenModel]] = orm.relationship( 1a
117 RecipeShareTokenModel, back_populates="recipe", cascade="all, delete, delete-orphan"
118 )
120 comments: Mapped[list[RecipeComment]] = orm.relationship( 1a
121 "RecipeComment", back_populates="recipe", cascade="all, delete, delete-orphan"
122 )
124 timeline_events: Mapped[list[RecipeTimelineEvent]] = orm.relationship( 1a
125 "RecipeTimelineEvent", back_populates="recipe", cascade="all, delete, delete-orphan"
126 )
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
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
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 )
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 )
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 )
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
179 @validates("name") 1a
180 def validate_name(self, _, name): 1a
181 assert name != ""
182 return name
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 {}))
201 if recipe_instructions is not None:
202 self.recipe_instructions = [RecipeInstruction(**step, session=session) for step in recipe_instructions]
204 if recipe_ingredient is not None:
205 self.recipe_ingredient = [RecipeIngredientModel(**ingr, session=session) for ingr in recipe_ingredient]
207 if assets:
208 self.assets = [RecipeAsset(**a) for a in assets]
210 self.settings = RecipeSettings(**(settings or {}))
212 if notes:
213 self.notes = [Note(**n) for n in notes]
215 self.date_updated = datetime.now(UTC)
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)
221 if description is not None:
222 self.description_normalized = self.normalize(description)
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 ]
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)
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)
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
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
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
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 )