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:
Michael Genson
2025-01-13 10:19:49 -06:00
committed by GitHub
parent e565b919df
commit e9892aba89
53 changed files with 1618 additions and 400 deletions

View File

@@ -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 ###

View File

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

View File

@@ -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",
}
)

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

View File

@@ -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(

View File

@@ -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(

View File

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

View File

@@ -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(

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,