Coverage for opt/mealie/lib/python3.12/site-packages/mealie/db/models/household/shopping_list.py: 94%
147 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 contextvars import ContextVar
2from datetime import UTC, datetime
3from typing import TYPE_CHECKING, Optional
5from pydantic import ConfigDict
6from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, UniqueConstraint, event, orm
7from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
8from sqlalchemy.ext.orderinglist import ordering_list
9from sqlalchemy.orm import Mapped, mapped_column
11from mealie.db.models.labels import MultiPurposeLabel
12from mealie.db.models.recipe.api_extras import ShoppingListExtras, ShoppingListItemExtras, api_extras
14from .._model_base import BaseMixins, SqlAlchemyBase
15from .._model_utils.auto_init import auto_init
16from .._model_utils.guid import GUID
17from ..recipe.ingredient import IngredientFoodModel, IngredientUnitModel
19if TYPE_CHECKING: 19 ↛ 20line 19 didn't jump to line 20 because the condition on line 19 was never true
20 from ..group import Group
21 from ..recipe import RecipeModel
22 from ..users import User
23 from .household import Household
26class ShoppingListItemRecipeReference(BaseMixins, SqlAlchemyBase):
27 __tablename__ = "shopping_list_item_recipe_reference"
28 id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
30 shopping_list_item: Mapped["ShoppingListItem"] = orm.relationship(
31 "ShoppingListItem", back_populates="recipe_references"
32 )
33 shopping_list_item_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_list_items.id"), primary_key=True)
35 recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
36 recipe: Mapped[Optional["RecipeModel"]] = orm.relationship("RecipeModel", back_populates="shopping_list_item_refs")
37 recipe_quantity: Mapped[float] = mapped_column(Float, nullable=False)
38 recipe_scale: Mapped[float] = mapped_column(Float, default=1)
39 recipe_note: Mapped[str | None] = mapped_column(String)
41 group_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "group_id")
42 household_id: AssociationProxy[GUID] = association_proxy("shopping_list_item", "household_id")
44 @auto_init()
45 def __init__(self, **_) -> None:
46 pass
49class ShoppingListItem(SqlAlchemyBase, BaseMixins):
50 __tablename__ = "shopping_list_items"
52 # Id's
53 id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
54 shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="list_items")
55 shopping_list_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("shopping_lists.id"), index=True)
57 group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id")
58 household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
60 # Meta
61 is_ingredient: Mapped[bool | None] = mapped_column(Boolean, default=True)
62 position: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
63 checked: Mapped[bool | None] = mapped_column(Boolean, default=False)
65 quantity: Mapped[float | None] = mapped_column(Float, default=1)
66 note: Mapped[str | None] = mapped_column(String)
68 extras: Mapped[list[ShoppingListItemExtras]] = orm.relationship(
69 "ShoppingListItemExtras", cascade="all, delete-orphan"
70 )
72 # Scaling Items
73 unit_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_units.id"))
74 unit: Mapped[IngredientUnitModel | None] = orm.relationship(IngredientUnitModel, uselist=False)
76 food_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("ingredient_foods.id"))
77 food: Mapped[IngredientFoodModel | None] = orm.relationship(IngredientFoodModel, uselist=False)
79 label_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"))
80 label: Mapped[MultiPurposeLabel | None] = orm.relationship(
81 MultiPurposeLabel, uselist=False, back_populates="shopping_list_items"
82 )
84 # Recipe Reference
85 recipe_references: Mapped[list[ShoppingListItemRecipeReference]] = orm.relationship(
86 ShoppingListItemRecipeReference, cascade="all, delete, delete-orphan"
87 )
88 model_config = ConfigDict(exclude={"label", "food", "unit"})
90 # Deprecated
91 is_food: Mapped[bool | None] = mapped_column(Boolean, default=False)
93 @api_extras
94 @auto_init()
95 def __init__(self, **_) -> None:
96 pass
99class ShoppingListRecipeReference(BaseMixins, SqlAlchemyBase):
100 __tablename__ = "shopping_list_recipe_reference"
101 id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
103 shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="recipe_references")
104 shopping_list_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
105 group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id")
106 household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
108 recipe_id: Mapped[GUID | None] = mapped_column(GUID, ForeignKey("recipes.id"), index=True)
109 recipe: Mapped[Optional["RecipeModel"]] = orm.relationship(
110 "RecipeModel", uselist=False, back_populates="shopping_list_refs"
111 )
113 recipe_quantity: Mapped[float] = mapped_column(Float, nullable=False)
114 model_config = ConfigDict(exclude={"id", "recipe"})
116 @auto_init()
117 def __init__(self, **_) -> None:
118 pass
121class ShoppingListMultiPurposeLabel(SqlAlchemyBase, BaseMixins):
122 __tablename__ = "shopping_lists_multi_purpose_labels"
123 __table_args__ = (UniqueConstraint("shopping_list_id", "label_id", name="shopping_list_id_label_id_key"),)
124 id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
126 shopping_list_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("shopping_lists.id"), primary_key=True)
127 shopping_list: Mapped["ShoppingList"] = orm.relationship("ShoppingList", back_populates="label_settings")
129 label_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("multi_purpose_labels.id"), primary_key=True)
130 label: Mapped["MultiPurposeLabel"] = orm.relationship(
131 "MultiPurposeLabel", back_populates="shopping_lists_label_settings"
132 )
134 group_id: AssociationProxy[GUID] = association_proxy("shopping_list", "group_id")
135 household_id: AssociationProxy[GUID] = association_proxy("shopping_list", "household_id")
137 position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
138 model_config = ConfigDict(exclude={"label"})
140 @auto_init()
141 def __init__(self, **_) -> None:
142 pass
145class ShoppingList(SqlAlchemyBase, BaseMixins):
146 __tablename__ = "shopping_lists"
147 id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
149 group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
150 group: Mapped["Group"] = orm.relationship("Group", back_populates="shopping_lists")
151 household_id: AssociationProxy[GUID] = association_proxy("user", "household_id")
152 household: AssociationProxy["Household"] = association_proxy("user", "household")
153 user_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("users.id"), nullable=False, index=True)
154 user: Mapped["User"] = orm.relationship("User", back_populates="shopping_lists")
156 name: Mapped[str | None] = mapped_column(String)
157 list_items: Mapped[list[ShoppingListItem]] = orm.relationship(
158 ShoppingListItem,
159 cascade="all, delete, delete-orphan",
160 order_by="ShoppingListItem.position",
161 collection_class=ordering_list("position"),
162 )
164 recipe_references: Mapped[list[ShoppingListRecipeReference]] = orm.relationship(
165 ShoppingListRecipeReference, cascade="all, delete, delete-orphan"
166 )
167 label_settings: Mapped[list["ShoppingListMultiPurposeLabel"]] = orm.relationship(
168 ShoppingListMultiPurposeLabel,
169 cascade="all, delete, delete-orphan",
170 order_by="ShoppingListMultiPurposeLabel.position",
171 collection_class=ordering_list("position"),
172 )
173 extras: Mapped[list[ShoppingListExtras]] = orm.relationship("ShoppingListExtras", cascade="all, delete-orphan")
174 model_config = ConfigDict(exclude={"id", "list_items"})
176 @api_extras
177 @auto_init()
178 def __init__(self, **_) -> None:
179 pass
182class SessionBuffer:
183 def __init__(self) -> None:
184 self.shopping_list_ids: set[GUID] = set()
186 def add(self, shopping_list_id: GUID) -> None:
187 self.shopping_list_ids.add(shopping_list_id)
189 def pop(self) -> GUID | None:
190 try:
191 return self.shopping_list_ids.pop()
192 except KeyError:
193 return None
195 def clear(self) -> None:
196 self.shopping_list_ids.clear()
199session_buffer_context = ContextVar("session_buffer", default=SessionBuffer()) # noqa: B039
202@event.listens_for(ShoppingListItem, "after_insert")
203@event.listens_for(ShoppingListItem, "after_update")
204@event.listens_for(ShoppingListItem, "after_delete")
205def buffer_shopping_list_updates(_, connection, target: ShoppingListItem):
206 """Adds the shopping list id to the session buffer so its `updated_at` property can be updated later"""
208 session_buffer = session_buffer_context.get()
209 session_buffer.add(target.shopping_list_id)
212@event.listens_for(orm.Session, "after_flush")
213def update_shopping_lists(session: orm.Session, _):
214 """Pulls all pending shopping list updates from the buffer and updates their `updated_at` property"""
216 session_buffer = session_buffer_context.get()
217 if not session_buffer.shopping_list_ids:
218 return
220 local_session = orm.Session(bind=session.connection())
221 try:
222 local_session.begin()
223 while True:
224 shopping_list_id = session_buffer.pop()
225 if not shopping_list_id:
226 break
228 shopping_list = local_session.query(ShoppingList).filter(ShoppingList.id == shopping_list_id).first()
229 if not shopping_list:
230 continue
232 shopping_list.updated_at = datetime.now(UTC)
233 local_session.commit()
234 except Exception:
235 local_session.rollback()
236 raise