mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-06-09 18:40:14 -04:00
feat: Move "on hand" and "last made" to household (#4616)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
"""add household to recipe last made, household to foods, and household to tools
|
||||
|
||||
Revision ID: b9e516e2d3b3
|
||||
Revises: b1020f328e98
|
||||
Create Date: 2024-11-20 17:30:41.152332
|
||||
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import orm
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
from mealie.core.root_logger import get_logger
|
||||
from mealie.db.models._model_utils.datetime import NaiveDateTime
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b9e516e2d3b3"
|
||||
down_revision: str | None = "b1020f328e98"
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class SqlAlchemyBase(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
# Intermediate table definitions
|
||||
class Group(SqlAlchemyBase):
|
||||
__tablename__ = "groups"
|
||||
|
||||
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
|
||||
|
||||
class Household(SqlAlchemyBase):
|
||||
__tablename__ = "households"
|
||||
|
||||
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
|
||||
|
||||
|
||||
class RecipeModel(SqlAlchemyBase):
|
||||
__tablename__ = "recipes"
|
||||
|
||||
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
|
||||
last_made: orm.Mapped[datetime | None] = orm.mapped_column(NaiveDateTime)
|
||||
|
||||
|
||||
class HouseholdToRecipe(SqlAlchemyBase):
|
||||
__tablename__ = "households_to_recipes"
|
||||
|
||||
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
household_id = sa.Column(GUID, sa.ForeignKey("households.id"), index=True, primary_key=True)
|
||||
recipe_id = sa.Column(GUID, sa.ForeignKey("recipes.id"), index=True, primary_key=True)
|
||||
last_made: orm.Mapped[datetime | None] = orm.mapped_column(NaiveDateTime)
|
||||
|
||||
|
||||
class IngredientFoodModel(SqlAlchemyBase):
|
||||
__tablename__ = "ingredient_foods"
|
||||
|
||||
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
|
||||
on_hand: orm.Mapped[bool] = orm.mapped_column(sa.Boolean, default=False)
|
||||
|
||||
|
||||
class Tool(SqlAlchemyBase):
|
||||
__tablename__ = "tools"
|
||||
|
||||
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
|
||||
on_hand: orm.Mapped[bool | None] = orm.mapped_column(sa.Boolean, default=False)
|
||||
|
||||
|
||||
def migrate_recipe_last_made_to_household(session: orm.Session):
|
||||
for group in session.query(Group).all():
|
||||
households = session.query(Household).filter(Household.group_id == group.id).all()
|
||||
recipes = (
|
||||
session.query(RecipeModel)
|
||||
.filter(
|
||||
RecipeModel.group_id == group.id,
|
||||
RecipeModel.last_made != None, # noqa E711
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
for recipe in recipes:
|
||||
for household in households:
|
||||
session.add(
|
||||
HouseholdToRecipe(
|
||||
household_id=household.id,
|
||||
recipe_id=recipe.id,
|
||||
last_made=recipe.last_made,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def migrate_foods_on_hand_to_household(session: orm.Session):
|
||||
dialect = op.get_bind().dialect
|
||||
|
||||
for group in session.query(Group).all():
|
||||
households = session.query(Household).filter(Household.group_id == group.id).all()
|
||||
foods = (
|
||||
session.query(IngredientFoodModel)
|
||||
.filter(
|
||||
IngredientFoodModel.group_id == group.id,
|
||||
IngredientFoodModel.on_hand == True, # noqa E712
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
for food in foods:
|
||||
for household in households:
|
||||
session.execute(
|
||||
sa.text(
|
||||
"INSERT INTO households_to_ingredient_foods (household_id, food_id)"
|
||||
"VALUES (:household_id, :food_id)"
|
||||
),
|
||||
{
|
||||
"household_id": GUID.convert_value_to_guid(household.id, dialect),
|
||||
"food_id": GUID.convert_value_to_guid(food.id, dialect),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def migrate_tools_on_hand_to_household(session: orm.Session):
|
||||
dialect = op.get_bind().dialect
|
||||
|
||||
for group in session.query(Group).all():
|
||||
households = session.query(Household).filter(Household.group_id == group.id).all()
|
||||
tools = (
|
||||
session.query(Tool)
|
||||
.filter(
|
||||
Tool.group_id == group.id,
|
||||
Tool.on_hand == True, # noqa E712
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
for tool in tools:
|
||||
for household in households:
|
||||
session.execute(
|
||||
sa.text("INSERT INTO households_to_tools (household_id, tool_id) VALUES (:household_id, :tool_id)"),
|
||||
{
|
||||
"household_id": GUID.convert_value_to_guid(household.id, dialect),
|
||||
"tool_id": GUID.convert_value_to_guid(tool.id, dialect),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def migrate_to_new_models():
|
||||
bind = op.get_bind()
|
||||
session = orm.Session(bind=bind)
|
||||
|
||||
for migration_func in [
|
||||
migrate_recipe_last_made_to_household,
|
||||
migrate_foods_on_hand_to_household,
|
||||
migrate_tools_on_hand_to_household,
|
||||
]:
|
||||
try:
|
||||
logger.info(f"Running new model migration ({migration_func.__name__})")
|
||||
migration_func(session)
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
logger.error(f"Error during new model migration ({migration_func.__name__})")
|
||||
raise
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"households_to_recipes",
|
||||
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
|
||||
sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=False),
|
||||
sa.Column("recipe_id", mealie.db.migration_types.GUID(), nullable=False),
|
||||
sa.Column("last_made", mealie.db.migration_types.NaiveDateTime(), nullable=True),
|
||||
sa.Column("created_at", mealie.db.migration_types.NaiveDateTime(), nullable=True),
|
||||
sa.Column("update_at", mealie.db.migration_types.NaiveDateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["household_id"],
|
||||
["households.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["recipe_id"],
|
||||
["recipes.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", "household_id", "recipe_id"),
|
||||
sa.UniqueConstraint("household_id", "recipe_id", name="household_id_recipe_id_key"),
|
||||
)
|
||||
with op.batch_alter_table("households_to_recipes", schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f("ix_households_to_recipes_created_at"), ["created_at"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_households_to_recipes_household_id"), ["household_id"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_households_to_recipes_recipe_id"), ["recipe_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"households_to_tools",
|
||||
sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True),
|
||||
sa.Column("tool_id", mealie.db.migration_types.GUID(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["household_id"],
|
||||
["households.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tool_id"],
|
||||
["tools.id"],
|
||||
),
|
||||
sa.UniqueConstraint("household_id", "tool_id", name="household_id_tool_id_key"),
|
||||
)
|
||||
with op.batch_alter_table("households_to_tools", schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f("ix_households_to_tools_household_id"), ["household_id"], unique=False)
|
||||
batch_op.create_index(batch_op.f("ix_households_to_tools_tool_id"), ["tool_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"households_to_ingredient_foods",
|
||||
sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True),
|
||||
sa.Column("food_id", mealie.db.migration_types.GUID(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["food_id"],
|
||||
["ingredient_foods.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["household_id"],
|
||||
["households.id"],
|
||||
),
|
||||
sa.UniqueConstraint("household_id", "food_id", name="household_id_food_id_key"),
|
||||
)
|
||||
with op.batch_alter_table("households_to_ingredient_foods", schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f("ix_households_to_ingredient_foods_food_id"), ["food_id"], unique=False)
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_households_to_ingredient_foods_household_id"), ["household_id"], unique=False
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
migrate_to_new_models()
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("households_to_ingredient_foods", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_households_to_ingredient_foods_household_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_households_to_ingredient_foods_food_id"))
|
||||
|
||||
op.drop_table("households_to_ingredient_foods")
|
||||
with op.batch_alter_table("households_to_tools", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_households_to_tools_tool_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_households_to_tools_household_id"))
|
||||
|
||||
op.drop_table("households_to_tools")
|
||||
with op.batch_alter_table("households_to_recipes", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_households_to_recipes_recipe_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_households_to_recipes_household_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_households_to_recipes_created_at"))
|
||||
|
||||
op.drop_table("households_to_recipes")
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,6 +1,7 @@
|
||||
from .cookbook import CookBook
|
||||
from .events import GroupEventNotifierModel, GroupEventNotifierOptionsModel
|
||||
from .household import Household
|
||||
from .household_to_recipe import HouseholdToRecipe
|
||||
from .invite_tokens import GroupInviteToken
|
||||
from .mealplan import GroupMealPlan, GroupMealPlanRules
|
||||
from .preferences import HouseholdPreferencesModel
|
||||
@@ -24,6 +25,7 @@ __all__ = [
|
||||
"GroupMealPlanRules",
|
||||
"Household",
|
||||
"HouseholdPreferencesModel",
|
||||
"HouseholdToRecipe",
|
||||
"GroupRecipeAction",
|
||||
"ShoppingList",
|
||||
"ShoppingListExtras",
|
||||
|
||||
@@ -8,9 +8,13 @@ from sqlalchemy.orm import Mapped, mapped_column
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
from ..recipe.ingredient import households_to_ingredient_foods
|
||||
from ..recipe.tool import households_to_tools
|
||||
from .household_to_recipe import HouseholdToRecipe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..group import Group
|
||||
from ..recipe import IngredientFoodModel, RecipeModel, Tool
|
||||
from ..users import User
|
||||
from . import (
|
||||
CookBook,
|
||||
@@ -62,6 +66,18 @@ class Household(SqlAlchemyBase, BaseMixins):
|
||||
"GroupEventNotifierModel", **COMMON_ARGS
|
||||
)
|
||||
|
||||
made_recipes: Mapped[list["RecipeModel"]] = orm.relationship(
|
||||
"RecipeModel", secondary=HouseholdToRecipe.__tablename__, back_populates="made_by"
|
||||
)
|
||||
ingredient_foods_on_hand: Mapped[list["IngredientFoodModel"]] = orm.relationship(
|
||||
"IngredientFoodModel",
|
||||
secondary=households_to_ingredient_foods,
|
||||
back_populates="households_with_ingredient_food",
|
||||
)
|
||||
tools_on_hand: Mapped[list["Tool"]] = orm.relationship(
|
||||
"Tool", secondary=households_to_tools, back_populates="households_with_tool"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
exclude={
|
||||
"users",
|
||||
@@ -72,6 +88,7 @@ class Household(SqlAlchemyBase, BaseMixins):
|
||||
"invite_tokens",
|
||||
"group_event_notifiers",
|
||||
"group",
|
||||
"made_recipes",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
60
mealie/db/models/household/household_to_recipe.py
Normal file
60
mealie/db/models/household/household_to_recipe.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, UniqueConstraint, event
|
||||
from sqlalchemy.engine.base import Connection
|
||||
from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.db.models._model_utils.datetime import NaiveDateTime
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils.auto_init import auto_init
|
||||
from .._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..recipe import RecipeModel
|
||||
from .household import Household
|
||||
|
||||
|
||||
class HouseholdToRecipe(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "households_to_recipes"
|
||||
__table_args__ = (UniqueConstraint("household_id", "recipe_id", name="household_id_recipe_id_key"),)
|
||||
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
|
||||
household: Mapped["Household"] = relationship("Household", viewonly=True)
|
||||
household_id = Column(GUID, ForeignKey("households.id"), index=True, primary_key=True)
|
||||
recipe: Mapped["RecipeModel"] = relationship("RecipeModel", viewonly=True)
|
||||
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True, primary_key=True)
|
||||
group_id: AssociationProxy[GUID] = association_proxy("household", "group_id")
|
||||
|
||||
last_made: Mapped[datetime | None] = mapped_column(NaiveDateTime)
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def update_recipe_last_made(session: Session, target: HouseholdToRecipe):
|
||||
if not target.last_made:
|
||||
return
|
||||
|
||||
from mealie.db.models.recipe.recipe import RecipeModel
|
||||
|
||||
recipe = session.query(RecipeModel).filter(RecipeModel.id == target.recipe_id).first()
|
||||
if not recipe:
|
||||
return
|
||||
|
||||
recipe.last_made = recipe.last_made or target.last_made
|
||||
recipe.last_made = max(recipe.last_made, target.last_made)
|
||||
|
||||
|
||||
@event.listens_for(HouseholdToRecipe, "after_insert")
|
||||
@event.listens_for(HouseholdToRecipe, "after_update")
|
||||
@event.listens_for(HouseholdToRecipe, "after_delete")
|
||||
def update_recipe_rating_on_insert_or_delete(_, connection: Connection, target: HouseholdToRecipe):
|
||||
session = Session(bind=connection)
|
||||
|
||||
update_recipe_last_made(session, target)
|
||||
session.commit()
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pydantic import ConfigDict
|
||||
from sqlalchemy import Boolean, Float, ForeignKey, Integer, String, event, orm
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.orm.session import Session
|
||||
@@ -14,6 +15,16 @@ from .._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..group import Group
|
||||
from ..household import Household
|
||||
|
||||
|
||||
households_to_ingredient_foods = sa.Table(
|
||||
"households_to_ingredient_foods",
|
||||
SqlAlchemyBase.metadata,
|
||||
sa.Column("household_id", GUID, sa.ForeignKey("households.id"), index=True),
|
||||
sa.Column("food_id", GUID, sa.ForeignKey("ingredient_foods.id"), index=True),
|
||||
sa.UniqueConstraint("household_id", "food_id", name="household_id_food_id_key"),
|
||||
)
|
||||
|
||||
|
||||
class IngredientUnitModel(SqlAlchemyBase, BaseMixins):
|
||||
@@ -142,11 +153,13 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
|
||||
# ID Relationships
|
||||
group_id: Mapped[GUID] = mapped_column(GUID, ForeignKey("groups.id"), nullable=False, index=True)
|
||||
group: Mapped["Group"] = orm.relationship("Group", back_populates="ingredient_foods", foreign_keys=[group_id])
|
||||
households_with_ingredient_food: Mapped[list["Household"]] = orm.relationship(
|
||||
"Household", secondary=households_to_ingredient_foods, back_populates="ingredient_foods_on_hand"
|
||||
)
|
||||
|
||||
name: Mapped[str | None] = mapped_column(String)
|
||||
plural_name: Mapped[str | None] = mapped_column(String)
|
||||
description: Mapped[str | None] = mapped_column(String)
|
||||
on_hand: Mapped[bool] = mapped_column(Boolean)
|
||||
|
||||
ingredients: Mapped[list["RecipeIngredientModel"]] = orm.relationship(
|
||||
"RecipeIngredientModel", back_populates="food"
|
||||
@@ -165,20 +178,42 @@ class IngredientFoodModel(SqlAlchemyBase, BaseMixins):
|
||||
name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
|
||||
plural_name_normalized: Mapped[str | None] = mapped_column(sa.String, index=True)
|
||||
|
||||
model_config = ConfigDict(
|
||||
exclude={
|
||||
"households_with_ingredient_food",
|
||||
}
|
||||
)
|
||||
|
||||
# Deprecated
|
||||
on_hand: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
@api_extras
|
||||
@auto_init()
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
group_id: GUID,
|
||||
name: str | None = None,
|
||||
plural_name: str | None = None,
|
||||
households_with_ingredient_food: list[str] | None = None,
|
||||
**_,
|
||||
) -> None:
|
||||
from ..household import Household
|
||||
|
||||
if name is not None:
|
||||
self.name_normalized = self.normalize(name)
|
||||
if plural_name is not None:
|
||||
self.plural_name_normalized = self.normalize(plural_name)
|
||||
|
||||
if not households_with_ingredient_food:
|
||||
self.households_with_ingredient_food = []
|
||||
else:
|
||||
self.households_with_ingredient_food = (
|
||||
session.query(Household)
|
||||
.filter(Household.group_id == group_id, Household.slug.in_(households_with_ingredient_food))
|
||||
.all()
|
||||
)
|
||||
|
||||
tableargs = [
|
||||
sa.UniqueConstraint("name", "group_id", name="ingredient_foods_name_group_id_key"),
|
||||
sa.Index(
|
||||
|
||||
@@ -16,6 +16,7 @@ from mealie.db.models._model_utils.datetime import NaiveDateTime, get_utc_today
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from ..household.household_to_recipe import HouseholdToRecipe
|
||||
from ..users.user_to_recipe import UserToRecipe
|
||||
from .api_extras import ApiExtras, api_extras
|
||||
from .assets import RecipeAsset
|
||||
@@ -136,7 +137,11 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
# Time Stamp Properties
|
||||
date_added: Mapped[date | None] = mapped_column(sa.Date, default=get_utc_today)
|
||||
date_updated: Mapped[datetime | None] = mapped_column(NaiveDateTime)
|
||||
|
||||
last_made: Mapped[datetime | None] = mapped_column(NaiveDateTime)
|
||||
made_by: Mapped[list["Household"]] = orm.relationship(
|
||||
"Household", secondary=HouseholdToRecipe.__tablename__, back_populates="made_recipes"
|
||||
)
|
||||
|
||||
# Shopping List Refs
|
||||
shopping_list_refs: Mapped[list["ShoppingListRecipeReference"]] = orm.relationship(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import ConfigDict
|
||||
from slugify import slugify
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, String, Table, UniqueConstraint, orm
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
@@ -10,8 +11,17 @@ from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..group import Group
|
||||
from ..household import Household
|
||||
from . import RecipeModel
|
||||
|
||||
households_to_tools = Table(
|
||||
"households_to_tools",
|
||||
SqlAlchemyBase.metadata,
|
||||
Column("household_id", GUID, ForeignKey("households.id"), index=True),
|
||||
Column("tool_id", GUID, ForeignKey("tools.id"), index=True),
|
||||
UniqueConstraint("household_id", "tool_id", name="household_id_tool_id_key"),
|
||||
)
|
||||
|
||||
recipes_to_tools = Table(
|
||||
"recipes_to_tools",
|
||||
SqlAlchemyBase.metadata,
|
||||
@@ -40,11 +50,36 @@ class Tool(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
name: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String, index=True, nullable=False)
|
||||
on_hand: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||
|
||||
households_with_tool: Mapped[list["Household"]] = orm.relationship(
|
||||
"Household", secondary=households_to_tools, back_populates="tools_on_hand"
|
||||
)
|
||||
recipes: Mapped[list["RecipeModel"]] = orm.relationship(
|
||||
"RecipeModel", secondary=recipes_to_tools, back_populates="tools"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
exclude={
|
||||
"households_with_tool",
|
||||
}
|
||||
)
|
||||
|
||||
# Deprecated
|
||||
on_hand: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, name, **_) -> None:
|
||||
def __init__(
|
||||
self, session: orm.Session, group_id: GUID, name: str, households_with_tool: list[str] | None = None, **_
|
||||
) -> None:
|
||||
from ..household import Household
|
||||
|
||||
self.slug = slugify(name)
|
||||
|
||||
if not households_with_tool:
|
||||
self.households_with_tool = []
|
||||
else:
|
||||
self.households_with_tool = (
|
||||
session.query(Household)
|
||||
.filter(Household.group_id == group_id, Household.slug.in_(households_with_tool))
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ from mealie.db.models.group.preferences import GroupPreferencesModel
|
||||
from mealie.db.models.household.cookbook import CookBook
|
||||
from mealie.db.models.household.events import GroupEventNotifierModel
|
||||
from mealie.db.models.household.household import Household
|
||||
from mealie.db.models.household.household_to_recipe import HouseholdToRecipe
|
||||
from mealie.db.models.household.invite_tokens import GroupInviteToken
|
||||
from mealie.db.models.household.mealplan import GroupMealPlan, GroupMealPlanRules
|
||||
from mealie.db.models.household.preferences import HouseholdPreferencesModel
|
||||
@@ -37,7 +38,7 @@ from mealie.db.models.users.password_reset import PasswordResetModel
|
||||
from mealie.db.models.users.user_to_recipe import UserToRecipe
|
||||
from mealie.repos.repository_cookbooks import RepositoryCookbooks
|
||||
from mealie.repos.repository_foods import RepositoryFood
|
||||
from mealie.repos.repository_household import RepositoryHousehold
|
||||
from mealie.repos.repository_household import RepositoryHousehold, RepositoryHouseholdRecipes
|
||||
from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
|
||||
from mealie.repos.repository_units import RepositoryUnit
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||
@@ -52,7 +53,7 @@ from mealie.schema.household.group_shopping_list import (
|
||||
ShoppingListOut,
|
||||
ShoppingListRecipeRefOut,
|
||||
)
|
||||
from mealie.schema.household.household import HouseholdInDB
|
||||
from mealie.schema.household.household import HouseholdInDB, HouseholdRecipeOut
|
||||
from mealie.schema.household.household_preferences import ReadHouseholdPreferences
|
||||
from mealie.schema.household.invite_token import ReadInviteToken
|
||||
from mealie.schema.household.webhook import ReadWebhook
|
||||
@@ -231,6 +232,17 @@ class AllRepositories:
|
||||
household_id=self.household_id,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def household_recipes(self) -> RepositoryHouseholdRecipes:
|
||||
return RepositoryHouseholdRecipes(
|
||||
self.session,
|
||||
PK_ID,
|
||||
HouseholdToRecipe,
|
||||
HouseholdRecipeOut,
|
||||
group_id=self.group_id,
|
||||
household_id=self.household_id,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def cookbooks(self) -> RepositoryCookbooks:
|
||||
return RepositoryCookbooks(
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any, Generic, TypeVar
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import UUID4, BaseModel
|
||||
from sqlalchemy import Select, case, delete, func, nulls_first, nulls_last, select
|
||||
from sqlalchemy import ColumnElement, Select, case, delete, func, nulls_first, nulls_last, select
|
||||
from sqlalchemy.orm import InstrumentedAttribute
|
||||
from sqlalchemy.orm.session import Session
|
||||
from sqlalchemy.sql import sqltypes
|
||||
@@ -69,6 +69,10 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||
def household_id(self) -> UUID4 | None:
|
||||
return self._household_id
|
||||
|
||||
@property
|
||||
def column_aliases(self) -> dict[str, ColumnElement]:
|
||||
return {}
|
||||
|
||||
def _random_seed(self) -> str:
|
||||
return str(datetime.now(tz=UTC))
|
||||
|
||||
@@ -356,7 +360,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||
if pagination.query_filter:
|
||||
try:
|
||||
query_filter_builder = QueryFilterBuilder(pagination.query_filter)
|
||||
query = query_filter_builder.filter_query(query, model=self.model)
|
||||
query = query_filter_builder.filter_query(query, model=self.model, column_aliases=self.column_aliases)
|
||||
|
||||
except ValueError as e:
|
||||
self.logger.error(e)
|
||||
@@ -394,6 +398,8 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||
order_dir: OrderDirection,
|
||||
order_by_null: OrderByNullPosition | None,
|
||||
) -> Select:
|
||||
order_attr = self.column_aliases.get(order_attr.key, order_attr)
|
||||
|
||||
# queries handle uppercase and lowercase differently, which is undesirable
|
||||
if isinstance(order_attr.type, sqltypes.String):
|
||||
order_attr = func.lower(order_attr)
|
||||
|
||||
@@ -8,15 +8,20 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from mealie.db.models._model_base import SqlAlchemyBase
|
||||
from mealie.db.models.household.household import Household
|
||||
from mealie.db.models.household import Household, HouseholdToRecipe
|
||||
from mealie.db.models.recipe.category import Category
|
||||
from mealie.db.models.recipe.recipe import RecipeModel
|
||||
from mealie.db.models.recipe.tag import Tag
|
||||
from mealie.db.models.recipe.tool import Tool
|
||||
from mealie.db.models.users.users import User
|
||||
from mealie.repos.repository_generic import GroupRepositoryGeneric
|
||||
from mealie.schema.household.household import HouseholdCreate, HouseholdInDB, UpdateHousehold
|
||||
from mealie.schema.household.household_statistics import HouseholdStatistics
|
||||
from mealie.repos.repository_generic import GroupRepositoryGeneric, HouseholdRepositoryGeneric
|
||||
from mealie.schema.household import (
|
||||
HouseholdCreate,
|
||||
HouseholdInDB,
|
||||
HouseholdRecipeOut,
|
||||
HouseholdStatistics,
|
||||
UpdateHousehold,
|
||||
)
|
||||
|
||||
|
||||
class RepositoryHousehold(GroupRepositoryGeneric[HouseholdInDB, Household]):
|
||||
@@ -101,3 +106,15 @@ class RepositoryHousehold(GroupRepositoryGeneric[HouseholdInDB, Household]):
|
||||
total_tags=model_count(Tag, filter_household=False),
|
||||
total_tools=model_count(Tool, filter_household=False),
|
||||
)
|
||||
|
||||
|
||||
class RepositoryHouseholdRecipes(HouseholdRepositoryGeneric[HouseholdRecipeOut, HouseholdToRecipe]):
|
||||
def get_by_recipe(self, recipe_id: UUID4) -> HouseholdRecipeOut | None:
|
||||
if not self.household_id:
|
||||
raise Exception("household_id not set")
|
||||
|
||||
stmt = select(HouseholdToRecipe).filter(
|
||||
HouseholdToRecipe.household_id == self.household_id, HouseholdToRecipe.recipe_id == recipe_id
|
||||
)
|
||||
result = self.session.execute(stmt).scalars().one_or_none()
|
||||
return None if result is None else self.schema.model_validate(result)
|
||||
|
||||
@@ -11,25 +11,22 @@ from slugify import slugify
|
||||
from sqlalchemy import orm
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from mealie.db.models.household.household import Household
|
||||
from mealie.db.models.household import Household, HouseholdToRecipe
|
||||
from mealie.db.models.recipe.category import Category
|
||||
from mealie.db.models.recipe.ingredient import IngredientFoodModel, RecipeIngredientModel
|
||||
from mealie.db.models.recipe.ingredient import RecipeIngredientModel, households_to_ingredient_foods
|
||||
from mealie.db.models.recipe.recipe import RecipeModel
|
||||
from mealie.db.models.recipe.settings import RecipeSettings
|
||||
from mealie.db.models.recipe.tag import Tag
|
||||
from mealie.db.models.recipe.tool import Tool, recipes_to_tools
|
||||
from mealie.db.models.recipe.tool import Tool, households_to_tools, recipes_to_tools
|
||||
from mealie.db.models.users.user_to_recipe import UserToRecipe
|
||||
from mealie.db.models.users.users import User
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||
from mealie.schema.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary
|
||||
from mealie.schema.recipe.recipe_ingredient import IngredientFood
|
||||
from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponseItem
|
||||
from mealie.schema.recipe.recipe_tool import RecipeToolOut
|
||||
from mealie.schema.response.pagination import (
|
||||
OrderByNullPosition,
|
||||
OrderDirection,
|
||||
PaginationQuery,
|
||||
)
|
||||
from mealie.schema.response.pagination import PaginationQuery
|
||||
from mealie.schema.response.query_filter import QueryFilterBuilder
|
||||
|
||||
from ..db.models._model_base import SqlAlchemyBase
|
||||
@@ -39,11 +36,58 @@ from .repository_generic import HouseholdRepositoryGeneric
|
||||
class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
|
||||
user_id: UUID4 | None = None
|
||||
|
||||
@property
|
||||
def column_aliases(self):
|
||||
if not self.user_id:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"last_made": self._get_last_made_col_alias(),
|
||||
"rating": self._get_rating_col_alias(),
|
||||
}
|
||||
|
||||
def by_user(self: Self, user_id: UUID4) -> Self:
|
||||
"""Add a user_id to the repo, which will be used to handle recipe ratings"""
|
||||
"""Add a user_id to the repo, which will be used to handle recipe ratings and other user-specific data"""
|
||||
self.user_id = user_id
|
||||
return self
|
||||
|
||||
def _get_last_made_col_alias(self) -> sa.ColumnElement | None:
|
||||
"""Computed last_made which uses `HouseholdToRecipe.last_made` for the user's household, otherwise None"""
|
||||
|
||||
user_household_subquery = sa.select(User.household_id).where(User.id == self.user_id).scalar_subquery()
|
||||
return (
|
||||
sa.select(HouseholdToRecipe.last_made)
|
||||
.where(
|
||||
HouseholdToRecipe.recipe_id == self.model.id,
|
||||
HouseholdToRecipe.household_id == user_household_subquery,
|
||||
)
|
||||
.correlate(self.model)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
def _get_rating_col_alias(self) -> sa.ColumnElement | None:
|
||||
"""Computed rating which uses the user's rating if it exists, otherwise falling back to the recipe's rating"""
|
||||
|
||||
effective_rating = sa.case(
|
||||
(
|
||||
sa.exists().where(
|
||||
UserToRecipe.recipe_id == self.model.id,
|
||||
UserToRecipe.user_id == self.user_id,
|
||||
UserToRecipe.rating != None, # noqa E711
|
||||
UserToRecipe.rating > 0,
|
||||
),
|
||||
sa.select(sa.func.max(UserToRecipe.rating))
|
||||
.where(UserToRecipe.recipe_id == self.model.id, UserToRecipe.user_id == self.user_id)
|
||||
.correlate(self.model)
|
||||
.scalar_subquery(),
|
||||
),
|
||||
else_=sa.case(
|
||||
(self.model.rating == 0, None),
|
||||
else_=self.model.rating,
|
||||
),
|
||||
)
|
||||
return sa.cast(effective_rating, sa.Float)
|
||||
|
||||
def create(self, document: Recipe) -> Recipe: # type: ignore
|
||||
max_retries = 10
|
||||
original_name: str = document.name # type: ignore
|
||||
@@ -103,51 +147,6 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
|
||||
additional_ids = self.session.execute(sa.select(model.id).filter(model.slug.in_(slugs))).scalars().all()
|
||||
return ids + additional_ids
|
||||
|
||||
def add_order_attr_to_query(
|
||||
self,
|
||||
query: sa.Select,
|
||||
order_attr: orm.InstrumentedAttribute,
|
||||
order_dir: OrderDirection,
|
||||
order_by_null: OrderByNullPosition | None,
|
||||
) -> sa.Select:
|
||||
"""Special handling for ordering recipes by rating"""
|
||||
column_name = order_attr.key
|
||||
if column_name != "rating" or not self.user_id:
|
||||
return super().add_order_attr_to_query(query, order_attr, order_dir, order_by_null)
|
||||
|
||||
# calculate the effictive rating for the user by using the user's rating if it exists,
|
||||
# falling back to the recipe's rating if it doesn't
|
||||
effective_rating_column_name = "_effective_rating"
|
||||
query = query.add_columns(
|
||||
sa.case(
|
||||
(
|
||||
sa.exists().where(
|
||||
UserToRecipe.recipe_id == self.model.id,
|
||||
UserToRecipe.user_id == self.user_id,
|
||||
UserToRecipe.rating is not None,
|
||||
UserToRecipe.rating > 0,
|
||||
),
|
||||
sa.select(sa.func.max(UserToRecipe.rating))
|
||||
.where(UserToRecipe.recipe_id == self.model.id, UserToRecipe.user_id == self.user_id)
|
||||
.scalar_subquery(),
|
||||
),
|
||||
else_=sa.case((self.model.rating == 0, None), else_=self.model.rating),
|
||||
).label(effective_rating_column_name)
|
||||
)
|
||||
|
||||
order_attr = effective_rating_column_name
|
||||
if order_dir is OrderDirection.asc:
|
||||
order_attr = sa.asc(order_attr)
|
||||
elif order_dir is OrderDirection.desc:
|
||||
order_attr = sa.desc(order_attr)
|
||||
|
||||
if order_by_null is OrderByNullPosition.first:
|
||||
order_attr = sa.nulls_first(order_attr)
|
||||
else:
|
||||
order_attr = sa.nulls_last(order_attr)
|
||||
|
||||
return query.order_by(order_attr)
|
||||
|
||||
def page_all( # type: ignore
|
||||
self,
|
||||
pagination: PaginationQuery,
|
||||
@@ -320,33 +319,34 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
|
||||
if not params.order_by:
|
||||
params.order_by = "created_at"
|
||||
|
||||
food_ids_with_on_hand = list(set(food_ids or []))
|
||||
tool_ids_with_on_hand = list(set(tool_ids or []))
|
||||
user_food_ids = list(set(food_ids or []))
|
||||
user_tool_ids = list(set(tool_ids or []))
|
||||
|
||||
# preserve the original lists of ids before we add on_hand items
|
||||
user_food_ids = food_ids_with_on_hand.copy()
|
||||
user_tool_ids = tool_ids_with_on_hand.copy()
|
||||
food_ids_with_on_hand = user_food_ids.copy()
|
||||
tool_ids_with_on_hand = user_tool_ids.copy()
|
||||
|
||||
if params.include_foods_on_hand:
|
||||
foods_on_hand_query = sa.select(IngredientFoodModel.id).filter(
|
||||
IngredientFoodModel.on_hand == True, # noqa: E712 - required for SQLAlchemy comparison
|
||||
sa.not_(IngredientFoodModel.id.in_(food_ids_with_on_hand)),
|
||||
if params.include_foods_on_hand and self.user_id:
|
||||
foods_on_hand_query = (
|
||||
sa.select(households_to_ingredient_foods.c.food_id)
|
||||
.join(User, households_to_ingredient_foods.c.household_id == User.household_id)
|
||||
.filter(
|
||||
sa.not_(households_to_ingredient_foods.c.food_id.in_(food_ids_with_on_hand)),
|
||||
User.id == self.user_id,
|
||||
)
|
||||
)
|
||||
if self.group_id:
|
||||
foods_on_hand_query = foods_on_hand_query.filter(IngredientFoodModel.group_id == self.group_id)
|
||||
|
||||
foods_on_hand = self.session.execute(foods_on_hand_query).scalars().all()
|
||||
food_ids_with_on_hand.extend(foods_on_hand)
|
||||
if params.include_tools_on_hand:
|
||||
tools_on_hand_query = sa.select(Tool.id).filter(
|
||||
Tool.on_hand == True, # noqa: E712 - required for SQLAlchemy comparison
|
||||
sa.not_(
|
||||
Tool.id.in_(tool_ids_with_on_hand),
|
||||
),
|
||||
)
|
||||
if self.group_id:
|
||||
tools_on_hand_query = tools_on_hand_query.filter(Tool.group_id == self.group_id)
|
||||
|
||||
if params.include_tools_on_hand and self.user_id:
|
||||
tools_on_hand_query = (
|
||||
sa.select(households_to_tools.c.tool_id)
|
||||
.join(User, households_to_tools.c.household_id == User.household_id)
|
||||
.filter(
|
||||
sa.not_(households_to_tools.c.tool_id.in_(tool_ids_with_on_hand)),
|
||||
User.id == self.user_id,
|
||||
)
|
||||
)
|
||||
tools_on_hand = self.session.execute(tools_on_hand_query).scalars().all()
|
||||
tool_ids_with_on_hand.extend(tools_on_hand)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from fastapi import Depends, HTTPException, status
|
||||
from mealie.routes._base.base_controllers import BaseUserController
|
||||
from mealie.routes._base.controller import controller
|
||||
from mealie.routes._base.routers import UserAPIRouter
|
||||
from mealie.schema.household.household import HouseholdInDB
|
||||
from mealie.schema.household import HouseholdInDB, HouseholdRecipeSummary
|
||||
from mealie.schema.household.household_permissions import SetPermissions
|
||||
from mealie.schema.household.household_preferences import ReadHouseholdPreferences, UpdateHouseholdPreferences
|
||||
from mealie.schema.household.household_statistics import HouseholdStatistics
|
||||
@@ -27,6 +27,15 @@ class HouseholdSelfServiceController(BaseUserController):
|
||||
"""Returns the Household Data for the Current User"""
|
||||
return self.household
|
||||
|
||||
@router.get("/self/recipes/{recipe_slug}", response_model=HouseholdRecipeSummary)
|
||||
def get_household_recipe(self, recipe_slug: str):
|
||||
"""Returns recipe data for the current household"""
|
||||
response = self.service.get_household_recipe(recipe_slug)
|
||||
if not response:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Recipe not found")
|
||||
|
||||
return response
|
||||
|
||||
@router.get("/members", response_model=PaginationBase[UserOut])
|
||||
def get_household_members(self, q: PaginationQuery = Depends()):
|
||||
"""Returns all users belonging to the current household"""
|
||||
|
||||
@@ -53,7 +53,9 @@ class GroupMealplanController(BaseCrudController):
|
||||
"""
|
||||
|
||||
rules = self.repos.group_meal_plan_rules.get_rules(PlanRulesDay.from_date(plan_date), entry_type.value)
|
||||
cross_household_recipes = get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
|
||||
cross_household_recipes = get_repositories(
|
||||
self.session, group_id=self.group_id, household_id=None
|
||||
).recipes.by_user(self.user.id)
|
||||
|
||||
qf_string = " AND ".join([f"({rule.query_filter_string})" for rule in rules if rule.query_filter_string])
|
||||
recipes_data = cross_household_recipes.page_all(
|
||||
|
||||
@@ -46,6 +46,7 @@ class RecipeToolController(BaseUserController):
|
||||
|
||||
@router.put("/{item_id}", response_model=RecipeTool)
|
||||
def update_one(self, item_id: UUID4, data: RecipeToolCreate):
|
||||
data = mapper.cast(data, RecipeToolSave, group_id=self.group_id)
|
||||
return self.mixins.update_one(data, item_id)
|
||||
|
||||
@router.delete("/{item_id}", response_model=RecipeTool)
|
||||
|
||||
@@ -24,6 +24,7 @@ from mealie.core.dependencies import (
|
||||
get_temporary_zip_path,
|
||||
)
|
||||
from mealie.pkgs import cache
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.routes._base import controller
|
||||
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||
@@ -252,8 +253,9 @@ class RecipeController(BaseRecipeController):
|
||||
if cookbook_data is None:
|
||||
raise HTTPException(status_code=404, detail="cookbook not found")
|
||||
|
||||
# We use "group_recipes" here so we can return all recipes regardless of household. The query filter can include
|
||||
# a household_id to filter by household. We use the "by_user" so we can sort favorites correctly.
|
||||
# We use "group_recipes" here so we can return all recipes regardless of household. The query filter can
|
||||
# include a household_id to filter by household.
|
||||
# We use "by_user" so we can sort favorites and other user-specific data correctly.
|
||||
pagination_response = self.group_recipes.by_user(self.user.id).page_all(
|
||||
pagination=q,
|
||||
cookbook=cookbook_data,
|
||||
@@ -288,7 +290,11 @@ class RecipeController(BaseRecipeController):
|
||||
foods: list[UUID4] | None = Query(None),
|
||||
tools: list[UUID4] | None = Query(None),
|
||||
) -> RecipeSuggestionResponse:
|
||||
recipes = self.group_recipes.find_suggested_recipes(q, foods, tools)
|
||||
group_recipes_by_user = get_repositories(
|
||||
self.session, group_id=self.group_id, household_id=None
|
||||
).recipes.by_user(self.user.id)
|
||||
|
||||
recipes = group_recipes_by_user.find_suggested_recipes(q, foods, tools)
|
||||
response = RecipeSuggestionResponse(items=recipes)
|
||||
json_compatible_response = orjson.dumps(response.model_dump(by_alias=True))
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ class IngredientFoodsController(BaseUserController):
|
||||
|
||||
@router.put("/{item_id}", response_model=IngredientFood)
|
||||
def update_one(self, item_id: UUID4, data: CreateIngredientFood):
|
||||
data = mapper.cast(data, SaveIngredientFood, group_id=self.group_id)
|
||||
return self.mixins.update_one(data, item_id)
|
||||
|
||||
@router.delete("/{item_id}", response_model=IngredientFood)
|
||||
|
||||
@@ -46,6 +46,11 @@ from .household import (
|
||||
HouseholdCreate,
|
||||
HouseholdInDB,
|
||||
HouseholdPagination,
|
||||
HouseholdRecipeBase,
|
||||
HouseholdRecipeCreate,
|
||||
HouseholdRecipeOut,
|
||||
HouseholdRecipeSummary,
|
||||
HouseholdRecipeUpdate,
|
||||
HouseholdSave,
|
||||
HouseholdSummary,
|
||||
HouseholdUserSummary,
|
||||
@@ -91,6 +96,11 @@ __all__ = [
|
||||
"HouseholdCreate",
|
||||
"HouseholdInDB",
|
||||
"HouseholdPagination",
|
||||
"HouseholdRecipeBase",
|
||||
"HouseholdRecipeCreate",
|
||||
"HouseholdRecipeOut",
|
||||
"HouseholdRecipeSummary",
|
||||
"HouseholdRecipeUpdate",
|
||||
"HouseholdSave",
|
||||
"HouseholdSummary",
|
||||
"HouseholdUserSummary",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import UUID4, ConfigDict, StringConstraints, field_validator
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
from sqlalchemy.orm.interfaces import LoaderOption
|
||||
|
||||
from mealie.db.models.household.household import Household
|
||||
from mealie.db.models.household import Household, HouseholdToRecipe
|
||||
from mealie.db.models.users.users import User
|
||||
from mealie.schema._mealie.mealie_model import MealieModel
|
||||
from mealie.schema.household.webhook import ReadWebhook
|
||||
@@ -13,6 +14,34 @@ from mealie.schema.response.pagination import PaginationBase
|
||||
from .household_preferences import ReadHouseholdPreferences, UpdateHouseholdPreferences
|
||||
|
||||
|
||||
class HouseholdRecipeBase(MealieModel):
|
||||
last_made: datetime | None = None
|
||||
|
||||
|
||||
class HouseholdRecipeSummary(HouseholdRecipeBase):
|
||||
recipe_id: UUID4
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class HouseholdRecipeCreate(HouseholdRecipeBase):
|
||||
household_id: UUID4
|
||||
recipe_id: UUID4
|
||||
|
||||
|
||||
class HouseholdRecipeUpdate(HouseholdRecipeBase): ...
|
||||
|
||||
|
||||
class HouseholdRecipeOut(HouseholdRecipeCreate):
|
||||
id: UUID4
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [
|
||||
joinedload(HouseholdToRecipe.household),
|
||||
]
|
||||
|
||||
|
||||
class HouseholdCreate(MealieModel):
|
||||
group_id: UUID4 | None = None
|
||||
name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
|
||||
@@ -59,7 +59,17 @@ class RecipeCategoryPagination(PaginationBase):
|
||||
|
||||
class RecipeTool(RecipeTag):
|
||||
id: UUID4
|
||||
on_hand: bool = False
|
||||
households_with_tool: list[str] = []
|
||||
|
||||
@field_validator("households_with_tool", mode="before")
|
||||
def convert_households_to_slugs(cls, v):
|
||||
if not v:
|
||||
return []
|
||||
|
||||
try:
|
||||
return [household.slug for household in v]
|
||||
except AttributeError:
|
||||
return v
|
||||
|
||||
|
||||
class RecipeToolPagination(PaginationBase):
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import ClassVar
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import UUID4, ConfigDict, Field, field_validator, model_validator
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
from sqlalchemy.orm.interfaces import LoaderOption
|
||||
|
||||
from mealie.db.models.recipe import IngredientFoodModel
|
||||
@@ -37,7 +37,6 @@ class UnitFoodBase(MealieModel):
|
||||
plural_name: str | None = None
|
||||
description: str = ""
|
||||
extras: dict | None = {}
|
||||
on_hand: bool = False
|
||||
|
||||
@field_validator("id", mode="before")
|
||||
def convert_empty_id_to_none(cls, v):
|
||||
@@ -67,6 +66,7 @@ class IngredientFoodAlias(CreateIngredientFoodAlias):
|
||||
class CreateIngredientFood(UnitFoodBase):
|
||||
label_id: UUID4 | None = None
|
||||
aliases: list[CreateIngredientFoodAlias] = []
|
||||
households_with_ingredient_food: list[str] = []
|
||||
|
||||
|
||||
class SaveIngredientFood(CreateIngredientFood):
|
||||
@@ -91,10 +91,24 @@ class IngredientFood(CreateIngredientFood):
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [
|
||||
selectinload(IngredientFoodModel.households_with_ingredient_food),
|
||||
joinedload(IngredientFoodModel.extras),
|
||||
joinedload(IngredientFoodModel.label),
|
||||
]
|
||||
|
||||
@field_validator("households_with_ingredient_food", mode="before")
|
||||
def convert_households_to_slugs(cls, v):
|
||||
if not v:
|
||||
return []
|
||||
|
||||
try:
|
||||
return [household.slug for household in v]
|
||||
except AttributeError:
|
||||
return v
|
||||
|
||||
def is_on_hand(self, household_slug: str) -> bool:
|
||||
return household_slug in self.households_with_tool
|
||||
|
||||
|
||||
class IngredientFoodPagination(PaginationBase):
|
||||
items: list[IngredientFood]
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
from pydantic import UUID4, ConfigDict
|
||||
from pydantic import UUID4, ConfigDict, field_validator
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm.interfaces import LoaderOption
|
||||
|
||||
from mealie.db.models.recipe import RecipeModel, Tool
|
||||
from mealie.schema._mealie import MealieModel
|
||||
|
||||
from ...db.models.recipe import RecipeModel, Tool
|
||||
|
||||
|
||||
class RecipeToolCreate(MealieModel):
|
||||
name: str
|
||||
on_hand: bool = False
|
||||
households_with_tool: list[str] = []
|
||||
|
||||
|
||||
class RecipeToolSave(RecipeToolCreate):
|
||||
@@ -19,8 +18,28 @@ class RecipeToolSave(RecipeToolCreate):
|
||||
class RecipeToolOut(RecipeToolCreate):
|
||||
id: UUID4
|
||||
slug: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@field_validator("households_with_tool", mode="before")
|
||||
def convert_households_to_slugs(cls, v):
|
||||
if not v:
|
||||
return []
|
||||
|
||||
try:
|
||||
return [household.slug for household in v]
|
||||
except AttributeError:
|
||||
return v
|
||||
|
||||
def is_on_hand(self, household_slug: str) -> bool:
|
||||
return household_slug in self.households_with_tool
|
||||
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [
|
||||
selectinload(Tool.households_with_tool),
|
||||
]
|
||||
|
||||
|
||||
class RecipeToolResponse(RecipeToolOut):
|
||||
recipes: list["RecipeSummary"] = []
|
||||
@@ -29,6 +48,7 @@ class RecipeToolResponse(RecipeToolOut):
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [
|
||||
selectinload(Tool.households_with_tool),
|
||||
selectinload(Tool.recipes).joinedload(RecipeModel.recipe_category),
|
||||
selectinload(Tool.recipes).joinedload(RecipeModel.tags),
|
||||
selectinload(Tool.recipes).joinedload(RecipeModel.tools),
|
||||
|
||||
@@ -6,10 +6,10 @@ from enum import Enum
|
||||
from typing import Any, TypeVar, cast
|
||||
from uuid import UUID
|
||||
|
||||
import sqlalchemy as sa
|
||||
from dateutil import parser as date_parser
|
||||
from dateutil.parser import ParserError
|
||||
from humps import decamelize
|
||||
from sqlalchemy import ColumnElement, Select, and_, inspect, or_
|
||||
from sqlalchemy.ext.associationproxy import AssociationProxyInstance
|
||||
from sqlalchemy.orm import InstrumentedAttribute, Mapper
|
||||
from sqlalchemy.sql import sqltypes
|
||||
@@ -251,17 +251,19 @@ class QueryFilterBuilder:
|
||||
return f"<<{joined}>>"
|
||||
|
||||
@classmethod
|
||||
def _consolidate_group(cls, group: list[ColumnElement], logical_operators: deque[LogicalOperator]) -> ColumnElement:
|
||||
consolidated_group_builder: ColumnElement | None = None
|
||||
def _consolidate_group(
|
||||
cls, group: list[sa.ColumnElement], logical_operators: deque[LogicalOperator]
|
||||
) -> sa.ColumnElement:
|
||||
consolidated_group_builder: sa.ColumnElement | None = None
|
||||
for i, element in enumerate(reversed(group)):
|
||||
if not i:
|
||||
consolidated_group_builder = element
|
||||
else:
|
||||
operator = logical_operators.pop()
|
||||
if operator is LogicalOperator.AND:
|
||||
consolidated_group_builder = and_(consolidated_group_builder, element)
|
||||
consolidated_group_builder = sa.and_(consolidated_group_builder, element)
|
||||
elif operator is LogicalOperator.OR:
|
||||
consolidated_group_builder = or_(consolidated_group_builder, element)
|
||||
consolidated_group_builder = sa.or_(consolidated_group_builder, element)
|
||||
else:
|
||||
raise ValueError(f"invalid logical operator {operator}")
|
||||
|
||||
@@ -270,8 +272,8 @@ class QueryFilterBuilder:
|
||||
|
||||
@classmethod
|
||||
def get_model_and_model_attr_from_attr_string(
|
||||
cls, attr_string: str, model: type[Model], *, query: Select | None = None
|
||||
) -> tuple[SqlAlchemyBase, InstrumentedAttribute, Select | None]:
|
||||
cls, attr_string: str, model: type[Model], *, query: sa.Select | None = None
|
||||
) -> tuple[SqlAlchemyBase, InstrumentedAttribute, sa.Select | None]:
|
||||
"""
|
||||
Take an attribute string and traverse a database model and its relationships to get the desired
|
||||
model and model attribute. Optionally provide a query to apply the necessary table joins.
|
||||
@@ -287,7 +289,7 @@ class QueryFilterBuilder:
|
||||
mapper: Mapper
|
||||
model_attr: InstrumentedAttribute | None = None
|
||||
|
||||
attribute_chain = attr_string.split(".")
|
||||
attribute_chain = decamelize(attr_string).split(".")
|
||||
if not attribute_chain:
|
||||
raise ValueError("invalid query string: attribute name cannot be empty")
|
||||
|
||||
@@ -306,7 +308,7 @@ class QueryFilterBuilder:
|
||||
if query is not None:
|
||||
query = query.join(model_attr, isouter=True)
|
||||
|
||||
mapper = inspect(current_model)
|
||||
mapper = sa.inspect(current_model)
|
||||
relationship = mapper.relationships[proxied_attribute_link]
|
||||
current_model = relationship.mapper.class_
|
||||
model_attr = getattr(current_model, next_attribute_link)
|
||||
@@ -318,7 +320,7 @@ class QueryFilterBuilder:
|
||||
if query is not None:
|
||||
query = query.join(model_attr, isouter=True)
|
||||
|
||||
mapper = inspect(current_model)
|
||||
mapper = sa.inspect(current_model)
|
||||
relationship = mapper.relationships[attribute_link]
|
||||
current_model = relationship.mapper.class_
|
||||
|
||||
@@ -330,7 +332,56 @@ class QueryFilterBuilder:
|
||||
|
||||
return current_model, model_attr, query
|
||||
|
||||
def filter_query(self, query: Select, model: type[Model]) -> Select:
|
||||
@staticmethod
|
||||
def _get_filter_element(
|
||||
component: QueryFilterBuilderComponent, model, model_attr, model_attr_type
|
||||
) -> sa.ColumnElement:
|
||||
# Keywords
|
||||
if component.relationship is RelationalKeyword.IS:
|
||||
element = model_attr.is_(component.validate(model_attr_type))
|
||||
elif component.relationship is RelationalKeyword.IS_NOT:
|
||||
element = model_attr.is_not(component.validate(model_attr_type))
|
||||
elif component.relationship is RelationalKeyword.IN:
|
||||
element = model_attr.in_(component.validate(model_attr_type))
|
||||
elif component.relationship is RelationalKeyword.NOT_IN:
|
||||
element = model_attr.not_in(component.validate(model_attr_type))
|
||||
elif component.relationship is RelationalKeyword.CONTAINS_ALL:
|
||||
primary_model_attr: InstrumentedAttribute = getattr(model, component.attribute_name.split(".")[0])
|
||||
element = sa.and_()
|
||||
for v in component.validate(model_attr_type):
|
||||
element = sa.and_(element, primary_model_attr.any(model_attr == v))
|
||||
elif component.relationship is RelationalKeyword.LIKE:
|
||||
element = model_attr.like(component.validate(model_attr_type))
|
||||
elif component.relationship is RelationalKeyword.NOT_LIKE:
|
||||
element = model_attr.not_like(component.validate(model_attr_type))
|
||||
|
||||
# Operators
|
||||
elif component.relationship is RelationalOperator.EQ:
|
||||
element = model_attr == component.validate(model_attr_type)
|
||||
elif component.relationship is RelationalOperator.NOTEQ:
|
||||
element = model_attr != component.validate(model_attr_type)
|
||||
elif component.relationship is RelationalOperator.GT:
|
||||
element = model_attr > component.validate(model_attr_type)
|
||||
elif component.relationship is RelationalOperator.LT:
|
||||
element = model_attr < component.validate(model_attr_type)
|
||||
elif component.relationship is RelationalOperator.GTE:
|
||||
element = model_attr >= component.validate(model_attr_type)
|
||||
elif component.relationship is RelationalOperator.LTE:
|
||||
element = model_attr <= component.validate(model_attr_type)
|
||||
else:
|
||||
raise ValueError(f"invalid relationship {component.relationship}")
|
||||
|
||||
return element
|
||||
|
||||
def filter_query(
|
||||
self, query: sa.Select, model: type[Model], column_aliases: dict[str, sa.ColumnElement] | None = None
|
||||
) -> sa.Select:
|
||||
"""
|
||||
Filters a query based on the parsed filter string.
|
||||
If you need to filter on a custom column expression (e.g. a computed property), you can supply column aliases
|
||||
"""
|
||||
column_aliases = column_aliases or {}
|
||||
|
||||
# join tables and build model chain
|
||||
attr_model_map: dict[int, Any] = {}
|
||||
model_attr: InstrumentedAttribute
|
||||
@@ -344,8 +395,8 @@ class QueryFilterBuilder:
|
||||
attr_model_map[i] = nested_model
|
||||
|
||||
# build query filter
|
||||
partial_group: list[ColumnElement] = []
|
||||
partial_group_stack: deque[list[ColumnElement]] = deque()
|
||||
partial_group: list[sa.ColumnElement] = []
|
||||
partial_group_stack: deque[list[sa.ColumnElement]] = deque()
|
||||
logical_operator_stack: deque[LogicalOperator] = deque()
|
||||
for i, component in enumerate(self.filter_components):
|
||||
if component == self.l_group_sep:
|
||||
@@ -365,43 +416,13 @@ class QueryFilterBuilder:
|
||||
|
||||
else:
|
||||
component = cast(QueryFilterBuilderComponent, component)
|
||||
model_attr = getattr(attr_model_map[i], component.attribute_name.split(".")[-1])
|
||||
base_attribute_name = component.attribute_name.split(".")[-1]
|
||||
model_attr = getattr(attr_model_map[i], base_attribute_name)
|
||||
|
||||
# Keywords
|
||||
if component.relationship is RelationalKeyword.IS:
|
||||
element = model_attr.is_(component.validate(model_attr.type))
|
||||
elif component.relationship is RelationalKeyword.IS_NOT:
|
||||
element = model_attr.is_not(component.validate(model_attr.type))
|
||||
elif component.relationship is RelationalKeyword.IN:
|
||||
element = model_attr.in_(component.validate(model_attr.type))
|
||||
elif component.relationship is RelationalKeyword.NOT_IN:
|
||||
element = model_attr.not_in(component.validate(model_attr.type))
|
||||
elif component.relationship is RelationalKeyword.CONTAINS_ALL:
|
||||
primary_model_attr: InstrumentedAttribute = getattr(model, component.attribute_name.split(".")[0])
|
||||
element = and_()
|
||||
for v in component.validate(model_attr.type):
|
||||
element = and_(element, primary_model_attr.any(model_attr == v))
|
||||
elif component.relationship is RelationalKeyword.LIKE:
|
||||
element = model_attr.like(component.validate(model_attr.type))
|
||||
elif component.relationship is RelationalKeyword.NOT_LIKE:
|
||||
element = model_attr.not_like(component.validate(model_attr.type))
|
||||
|
||||
# Operators
|
||||
elif component.relationship is RelationalOperator.EQ:
|
||||
element = model_attr == component.validate(model_attr.type)
|
||||
elif component.relationship is RelationalOperator.NOTEQ:
|
||||
element = model_attr != component.validate(model_attr.type)
|
||||
elif component.relationship is RelationalOperator.GT:
|
||||
element = model_attr > component.validate(model_attr.type)
|
||||
elif component.relationship is RelationalOperator.LT:
|
||||
element = model_attr < component.validate(model_attr.type)
|
||||
elif component.relationship is RelationalOperator.GTE:
|
||||
element = model_attr >= component.validate(model_attr.type)
|
||||
elif component.relationship is RelationalOperator.LTE:
|
||||
element = model_attr <= component.validate(model_attr.type)
|
||||
else:
|
||||
raise ValueError(f"invalid relationship {component.relationship}")
|
||||
if (column_alias := column_aliases.get(base_attribute_name)) is not None:
|
||||
model_attr = column_alias
|
||||
|
||||
element = self._get_filter_element(component, model, model_attr, model_attr.type)
|
||||
partial_group.append(element)
|
||||
|
||||
# combine the completed groups into one filter
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.core import exceptions
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.schema.household.household import HouseholdCreate
|
||||
from mealie.schema.household import HouseholdCreate, HouseholdRecipeSummary
|
||||
from mealie.schema.household.household import HouseholdRecipeCreate, HouseholdRecipeUpdate
|
||||
from mealie.schema.household.household_preferences import CreateHouseholdPreferences, SaveHouseholdPreferences
|
||||
from mealie.schema.household.household_statistics import HouseholdStatistics
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.services._base_service import BaseService
|
||||
|
||||
|
||||
@@ -15,6 +20,19 @@ class HouseholdService(BaseService):
|
||||
self.repos = repos
|
||||
super().__init__()
|
||||
|
||||
def _get_recipe(self, recipe_slug: str | UUID) -> Recipe | None:
|
||||
key = "id"
|
||||
if not isinstance(recipe_slug, UUID):
|
||||
try:
|
||||
UUID(recipe_slug)
|
||||
except ValueError:
|
||||
key = "slug"
|
||||
|
||||
cross_household_recipes = get_repositories(
|
||||
self.repos.session, group_id=self.group_id, household_id=None
|
||||
).recipes
|
||||
return cross_household_recipes.get_one(recipe_slug, key)
|
||||
|
||||
@staticmethod
|
||||
def create_household(
|
||||
repos: AllRepositories, h_base: HouseholdCreate, prefs: CreateHouseholdPreferences | None = None
|
||||
@@ -48,3 +66,34 @@ class HouseholdService(BaseService):
|
||||
household_id = household_id or self.household_id
|
||||
|
||||
return self.repos.households.statistics(group_id, household_id)
|
||||
|
||||
def get_household_recipe(self, recipe_slug: str) -> HouseholdRecipeSummary | None:
|
||||
"""Returns recipe data for the current household"""
|
||||
recipe = self._get_recipe(recipe_slug)
|
||||
if not recipe:
|
||||
return None
|
||||
|
||||
household_recipe_out = self.repos.household_recipes.get_by_recipe(recipe.id)
|
||||
if household_recipe_out:
|
||||
return household_recipe_out.cast(HouseholdRecipeSummary)
|
||||
else:
|
||||
return HouseholdRecipeSummary(recipe_id=recipe.id)
|
||||
|
||||
def set_household_recipe(self, recipe_slug: str | UUID, data: HouseholdRecipeUpdate) -> HouseholdRecipeSummary:
|
||||
"""Sets the household's recipe data"""
|
||||
recipe = self._get_recipe(recipe_slug)
|
||||
if not recipe:
|
||||
raise exceptions.NoEntryFound("Recipe not found.")
|
||||
|
||||
existing_household_recipe = self.repos.household_recipes.get_by_recipe(recipe.id)
|
||||
|
||||
if existing_household_recipe:
|
||||
updated_data = existing_household_recipe.cast(HouseholdRecipeUpdate, **data.model_dump())
|
||||
household_recipe_out = self.repos.household_recipes.patch(existing_household_recipe.id, updated_data)
|
||||
else:
|
||||
create_data = HouseholdRecipeCreate(
|
||||
household_id=self.household_id, recipe_id=recipe.id, **data.model_dump()
|
||||
)
|
||||
household_recipe_out = self.repos.household_recipes.create(create_data)
|
||||
|
||||
return household_recipe_out.cast(HouseholdRecipeSummary)
|
||||
|
||||
@@ -19,7 +19,7 @@ from mealie.pkgs import cache
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.repos.repository_generic import RepositoryGeneric
|
||||
from mealie.schema.household.household import HouseholdInDB
|
||||
from mealie.schema.household.household import HouseholdInDB, HouseholdRecipeUpdate
|
||||
from mealie.schema.openai.recipe import OpenAIRecipe
|
||||
from mealie.schema.recipe.recipe import CreateRecipe, Recipe
|
||||
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
|
||||
@@ -30,6 +30,7 @@ from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreat
|
||||
from mealie.schema.recipe.request_helpers import RecipeDuplicate
|
||||
from mealie.schema.user.user import PrivateUser, UserRatingCreate
|
||||
from mealie.services._base_service import BaseService
|
||||
from mealie.services.household_services.household_service import HouseholdService
|
||||
from mealie.services.openai import OpenAIDataInjection, OpenAILocalImage, OpenAIService
|
||||
from mealie.services.recipe.recipe_data_service import RecipeDataService
|
||||
from mealie.services.scraper import cleaner
|
||||
@@ -173,6 +174,7 @@ class RecipeService(RecipeServiceBase):
|
||||
data.settings = RecipeSettings()
|
||||
|
||||
rating_input = data.rating
|
||||
data.last_made = None
|
||||
new_recipe = self.repos.recipes.create(data)
|
||||
|
||||
# convert rating into user rating
|
||||
@@ -342,6 +344,7 @@ class RecipeService(RecipeServiceBase):
|
||||
if old_recipe.recipe_ingredient is None
|
||||
else list(map(copy_recipe_ingredient, old_recipe.recipe_ingredient))
|
||||
)
|
||||
new_recipe.last_made = None
|
||||
|
||||
new_recipe = self._recipe_creation_factory(new_name, additional_attrs=new_recipe.model_dump())
|
||||
|
||||
@@ -413,8 +416,11 @@ class RecipeService(RecipeServiceBase):
|
||||
def update_last_made(self, slug_or_id: str | UUID, timestamp: datetime) -> Recipe:
|
||||
# we bypass the pre update check since any user can update a recipe's last made date, even if it's locked,
|
||||
# or if the user belongs to a different household
|
||||
recipe = self.get_one(slug_or_id)
|
||||
return self.group_recipes.patch(recipe.slug, {"last_made": timestamp})
|
||||
|
||||
household_service = HouseholdService(self.user.group_id, self.user.household_id, self.repos)
|
||||
household_service.set_household_recipe(slug_or_id, HouseholdRecipeUpdate(last_made=timestamp))
|
||||
|
||||
return self.get_one(slug_or_id)
|
||||
|
||||
def delete_one(self, slug_or_id: str | UUID) -> Recipe:
|
||||
recipe = self.get_one(slug_or_id)
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from mealie.db.db_setup import session_context
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.schema.household.household import HouseholdRecipeUpdate
|
||||
from mealie.schema.meal_plan.new_meal import PlanEntryType
|
||||
from mealie.schema.recipe.recipe import RecipeSummary
|
||||
from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType
|
||||
@@ -18,12 +19,14 @@ from mealie.services.event_bus_service.event_types import (
|
||||
EventRecipeTimelineEventData,
|
||||
EventTypes,
|
||||
)
|
||||
from mealie.services.household_services.household_service import HouseholdService
|
||||
|
||||
|
||||
def _create_mealplan_timeline_events_for_household(
|
||||
event_time: datetime, session: Session, group_id: UUID4, household_id: UUID4
|
||||
) -> None:
|
||||
repos = get_repositories(session, group_id=group_id, household_id=household_id)
|
||||
household_service = HouseholdService(group_id, household_id, repos)
|
||||
event_bus_service = EventBusService(session=session)
|
||||
|
||||
timeline_events_to_create: list[RecipeTimelineEventCreate] = []
|
||||
@@ -64,7 +67,8 @@ def _create_mealplan_timeline_events_for_household(
|
||||
continue
|
||||
|
||||
# bump up the last made date
|
||||
last_made = mealplan.recipe.last_made
|
||||
household_to_recipe = household_service.get_household_recipe(mealplan.recipe.slug)
|
||||
last_made = household_to_recipe.last_made if household_to_recipe else None
|
||||
if (not last_made or last_made.date() < event_time.date()) and mealplan.recipe_id not in recipes_to_update:
|
||||
recipes_to_update[mealplan.recipe_id] = mealplan.recipe
|
||||
|
||||
@@ -99,6 +103,7 @@ def _create_mealplan_timeline_events_for_household(
|
||||
)
|
||||
|
||||
for recipe in recipes_to_update.values():
|
||||
household_service.set_household_recipe(recipe.slug, HouseholdRecipeUpdate(last_made=event_time))
|
||||
repos.recipes.patch(recipe.slug, {"last_made": event_time})
|
||||
event_bus_service.dispatch(
|
||||
integration_id=DEFAULT_INTEGRATION_ID,
|
||||
|
||||
Reference in New Issue
Block a user