feat: Recipe Finder (aka Cocktail Builder) (#4542)

This commit is contained in:
Michael Genson
2024-12-03 07:27:41 -06:00
committed by GitHub
parent d26e29d1c5
commit 4e0cf985bc
28 changed files with 1959 additions and 151 deletions

View File

@@ -16,7 +16,13 @@ from sqlalchemy.sql import sqltypes
from mealie.core.root_logger import get_logger
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.schema._mealie import MealieModel
from mealie.schema.response.pagination import OrderByNullPosition, OrderDirection, PaginationBase, PaginationQuery
from mealie.schema.response.pagination import (
OrderByNullPosition,
OrderDirection,
PaginationBase,
PaginationQuery,
RequestQuery,
)
from mealie.schema.response.query_filter import QueryFilterBuilder
from mealie.schema.response.query_search import SearchFilter
@@ -404,11 +410,11 @@ class RepositoryGeneric(Generic[Schema, Model]):
return query.order_by(order_attr)
def add_order_by_to_query(self, query: Select, pagination: PaginationQuery) -> Select:
if not pagination.order_by:
def add_order_by_to_query(self, query: Select, request_query: RequestQuery) -> Select:
if not request_query.order_by:
return query
elif pagination.order_by == "random":
elif request_query.order_by == "random":
# randomize outside of database, since not all db's can set random seeds
# this solution is db-independent & stable to paging
temp_query = query.with_only_columns(self.model.id)
@@ -417,14 +423,14 @@ class RepositoryGeneric(Generic[Schema, Model]):
return query
order = list(range(len(allids)))
random.seed(pagination.pagination_seed)
random.seed(request_query.pagination_seed)
random.shuffle(order)
random_dict = dict(zip(allids, order, strict=True))
case_stmt = case(random_dict, value=self.model.id)
return query.order_by(case_stmt)
else:
for order_by_val in pagination.order_by.split(","):
for order_by_val in request_query.order_by.split(","):
try:
order_by_val = order_by_val.strip()
if ":" in order_by_val:
@@ -432,20 +438,20 @@ class RepositoryGeneric(Generic[Schema, Model]):
order_dir = OrderDirection(order_dir_val)
else:
order_by = order_by_val
order_dir = pagination.order_direction
order_dir = request_query.order_direction
_, order_attr, query = QueryFilterBuilder.get_model_and_model_attr_from_attr_string(
order_by, self.model, query=query
)
query = self.add_order_attr_to_query(
query, order_attr, order_dir, pagination.order_by_null_position
query, order_attr, order_dir, request_query.order_by_null_position
)
except ValueError as e:
raise HTTPException(
status_code=400,
detail=f'Invalid order_by statement "{pagination.order_by}": "{order_by_val}" is invalid',
detail=f'Invalid order_by statement "{request_query.order_by}": "{order_by_val}" is invalid',
) from e
return query

View File

@@ -1,30 +1,37 @@
import re as re
from collections.abc import Sequence
from random import randint
from typing import cast
from uuid import UUID
import sqlalchemy as sa
from fastapi import HTTPException
from pydantic import UUID4
from slugify import slugify
from sqlalchemy import orm
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import InstrumentedAttribute
from typing_extensions import Self
from mealie.db.models.household.household import Household
from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.ingredient import RecipeIngredientModel
from mealie.db.models.recipe.ingredient import IngredientFoodModel, RecipeIngredientModel
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
from mealie.db.models.recipe.tool import Tool, recipes_to_tools
from mealie.db.models.users.user_to_recipe import UserToRecipe
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.query_filter import QueryFilterBuilder
from ..db.models._model_base import SqlAlchemyBase
from .repository_generic import HouseholdRepositoryGeneric
@@ -100,7 +107,7 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
def add_order_attr_to_query(
self,
query: sa.Select,
order_attr: InstrumentedAttribute,
order_attr: orm.InstrumentedAttribute,
order_dir: OrderDirection,
order_by_null: OrderByNullPosition | None,
) -> sa.Select:
@@ -297,3 +304,176 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]):
def all_ids(self, group_id: UUID4) -> Sequence[UUID4]:
stmt = sa.select(RecipeModel.id).filter(RecipeModel.group_id == group_id)
return self.session.execute(stmt).scalars().all()
def find_suggested_recipes(
self,
params: RecipeSuggestionQuery,
food_ids: list[UUID4] | None = None,
tool_ids: list[UUID4] | None = None,
) -> list[RecipeSuggestionResponseItem]:
"""
Queries all recipes and returns the ones that are missing the least amount of foods and tools.
Results are ordered first by number of missing tools, then foods, and finally by the user-specified order.
If foods are provided, the query will prefer recipes with more matches to user-provided foods.
"""
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 []))
# 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()
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 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)
tools_on_hand = self.session.execute(tools_on_hand_query).scalars().all()
tool_ids_with_on_hand.extend(tools_on_hand)
## Build suggestion query
settings_alias = orm.aliased(RecipeSettings)
ingredients_alias = orm.aliased(RecipeIngredientModel)
tools_alias = orm.aliased(Tool)
q = sa.select(self.model)
fltr = self._filter_builder()
q = q.filter_by(**fltr)
# Tools goes first so we can order by missing tools count before foods
if user_tool_ids:
unmatched_tools_query = (
sa.select(recipes_to_tools.c.recipe_id, sa.func.count().label("unmatched_tools_count"))
.join(tools_alias, recipes_to_tools.c.tool_id == tools_alias.id)
.filter(sa.not_(tools_alias.id.in_(tool_ids_with_on_hand)))
.group_by(recipes_to_tools.c.recipe_id)
.subquery()
)
q = (
q.outerjoin(unmatched_tools_query, self.model.id == unmatched_tools_query.c.recipe_id)
.filter(
sa.or_(
unmatched_tools_query.c.unmatched_tools_count.is_(None),
unmatched_tools_query.c.unmatched_tools_count <= params.max_missing_tools,
)
)
.order_by(unmatched_tools_query.c.unmatched_tools_count.asc().nulls_first())
)
if user_food_ids:
unmatched_foods_query = (
sa.select(ingredients_alias.recipe_id, sa.func.count().label("unmatched_foods_count"))
.filter(sa.not_(ingredients_alias.food_id.in_(food_ids_with_on_hand)))
.filter(ingredients_alias.food_id.isnot(None))
.group_by(ingredients_alias.recipe_id)
.subquery()
)
total_user_foods_query = (
sa.select(ingredients_alias.recipe_id, sa.func.count().label("total_user_foods_count"))
.filter(ingredients_alias.food_id.in_(user_food_ids))
.group_by(ingredients_alias.recipe_id)
.subquery()
)
q = (
q.join(settings_alias, self.model.settings)
.filter(settings_alias.disable_amount == False) # noqa: E712 - required for SQLAlchemy comparison
.outerjoin(unmatched_foods_query, self.model.id == unmatched_foods_query.c.recipe_id)
.outerjoin(total_user_foods_query, self.model.id == total_user_foods_query.c.recipe_id)
.filter(
sa.or_(
unmatched_foods_query.c.unmatched_foods_count.is_(None),
unmatched_foods_query.c.unmatched_foods_count <= params.max_missing_foods,
),
)
.order_by(
unmatched_foods_query.c.unmatched_foods_count.asc().nulls_first(),
# favor recipes with more matched foods, in case the user is looking for something specific
total_user_foods_query.c.total_user_foods_count.desc().nulls_last(),
)
)
# only include recipes that have at least one food in the user's list
if user_food_ids:
q = q.filter(total_user_foods_query.c.total_user_foods_count > 0)
## Add filters and loader options
if self.group_id:
q = q.filter(self.model.group_id == self.group_id)
if self.household_id:
q = q.filter(self.model.household_id == self.household_id)
if params.query_filter:
try:
query_filter_builder = QueryFilterBuilder(params.query_filter)
q = query_filter_builder.filter_query(q, model=self.model)
except ValueError as e:
self.logger.error(e)
raise HTTPException(status_code=400, detail=str(e)) from e
q = self.add_order_by_to_query(q, params)
q = q.limit(params.limit).options(*RecipeSummary.loader_options())
## Execute query
try:
data = self.session.execute(q).scalars().unique().all()
except Exception as e:
self._log_exception(e)
self.session.rollback()
raise e
suggestions: list[RecipeSuggestionResponseItem] = []
for result in data:
recipe = cast(RecipeModel, result)
missing_foods: list[IngredientFood] = []
if user_food_ids: # only check for missing foods if the user has provided a list of foods
seen_food_ids: set[UUID4] = set()
seen_food_ids.update(food_ids_with_on_hand)
for ingredient in recipe.recipe_ingredient:
if not ingredient.food:
continue
if ingredient.food.id in seen_food_ids:
continue
seen_food_ids.add(ingredient.food.id)
missing_foods.append(IngredientFood.model_validate(ingredient.food))
missing_tools: list[RecipeToolOut] = []
if user_tool_ids: # only check for missing tools if the user has provided a list of tools
seen_tool_ids: set[UUID4] = set()
seen_tool_ids.update(tool_ids_with_on_hand)
for tool in recipe.tools:
if tool.id in seen_tool_ids:
continue
seen_tool_ids.add(tool.id)
missing_tools.append(RecipeToolOut.model_validate(tool))
suggestion = RecipeSuggestionResponseItem(
recipe=RecipeSummary.model_validate(recipe),
missing_foods=missing_foods,
missing_tools=missing_tools,
)
suggestions.append(suggestion)
return suggestions