mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-27 20:55:12 -05:00
feat: Recipe Finder (aka Cocktail Builder) (#4542)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user