mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-29 13:27:09 -05: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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user