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

View File

@@ -11,6 +11,7 @@ from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.make_dependable import make_dependable
from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import RecipeSummary
from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponse
from mealie.schema.response.pagination import PaginationBase, PaginationQuery, RecipeSearchQuery
router = APIRouter(prefix="/recipes")
@@ -90,6 +91,26 @@ class PublicRecipesController(BasePublicHouseholdExploreController):
# Response is returned directly, to avoid validation and improve performance
return JSONBytes(content=json_compatible_response)
@router.get("/suggestions", response_model=RecipeSuggestionResponse)
def suggest_recipes(
self,
q: RecipeSuggestionQuery = Depends(make_dependable(RecipeSuggestionQuery)),
foods: list[UUID4] | None = Query(None),
tools: list[UUID4] | None = Query(None),
) -> RecipeSuggestionResponse:
public_filter = "(household.preferences.privateHousehold = FALSE AND settings.public = TRUE)"
if q.query_filter:
q.query_filter = f"({q.query_filter}) AND {public_filter}"
else:
q.query_filter = public_filter
recipes = self.cross_household_recipes.find_suggested_recipes(q, foods, tools)
response = RecipeSuggestionResponse(items=recipes)
json_compatible_response = orjson.dumps(response.model_dump(by_alias=True))
# Response is returned directly, to avoid validation and improve performance
return JSONBytes(content=json_compatible_response)
@router.get("/{recipe_slug}", response_model=Recipe)
def get_recipe(self, recipe_slug: str) -> Recipe:
RECIPE_NOT_FOUND_EXCEPTION = HTTPException(404, "recipe not found")

View File

@@ -1,14 +1,14 @@
from fastapi import APIRouter
from . import bulk_actions, comments, recipe_crud_routes, shared_routes, timeline_events
from . import bulk_actions, comments, exports, recipe_crud_routes, shared_routes, timeline_events
prefix = "/recipes"
router = APIRouter()
router.include_router(recipe_crud_routes.router_exports, tags=["Recipe: Exports"])
router.include_router(exports.router, tags=["Recipe: Exports"])
router.include_router(recipe_crud_routes.router, tags=["Recipe: CRUD"])
router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"])
router.include_router(bulk_actions.router, prefix=prefix, tags=["Recipe: Bulk Actions"])
router.include_router(shared_routes.router, prefix=prefix, tags=["Recipe: Shared"])
router.include_router(timeline_events.events_router, prefix=prefix, tags=["Recipe: Timeline"])
router.include_router(timeline_events.router, prefix=prefix, tags=["Recipe: Timeline"])

View File

@@ -0,0 +1,57 @@
from functools import cached_property
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from mealie.db.models.household.cookbook import CookBook
from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_generic import RepositoryGeneric
from mealie.repos.repository_recipes import RepositoryRecipes
from mealie.routes._base import BaseCrudController
from mealie.routes._base.mixins import HttpRepo
from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import (
CreateRecipe,
)
from mealie.services.recipe.recipe_service import RecipeService
class JSONBytes(JSONResponse):
"""
JSONBytes overrides the render method to return the bytes instead of a string.
You can use this when you want to use orjson and bypass the jsonable_encoder
"""
media_type = "application/json"
def render(self, content: bytes) -> bytes:
return content
class FormatResponse(BaseModel):
jjson: list[str] = Field(..., alias="json")
zip: list[str]
jinja2: list[str]
class BaseRecipeController(BaseCrudController):
@cached_property
def recipes(self) -> RepositoryRecipes:
return self.repos.recipes
@cached_property
def group_recipes(self) -> RepositoryRecipes:
return get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
@cached_property
def group_cookbooks(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
return get_repositories(self.session, group_id=self.group_id, household_id=None).cookbooks
@cached_property
def service(self) -> RecipeService:
return RecipeService(self.repos, self.user, self.household, translator=self.translator)
@cached_property
def mixins(self):
return HttpRepo[CreateRecipe, Recipe, Recipe](self.recipes, self.logger)

View File

@@ -0,0 +1,76 @@
from shutil import rmtree
from zipfile import ZipFile
from fastapi import (
HTTPException,
)
from starlette.background import BackgroundTask
from starlette.responses import FileResponse
from mealie.core.dependencies import (
get_temporary_path,
get_temporary_zip_path,
validate_recipe_token,
)
from mealie.core.security import create_recipe_slug_token
from mealie.routes._base import controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.schema.recipe import Recipe, RecipeImageTypes
from mealie.schema.recipe.request_helpers import (
RecipeZipTokenResponse,
)
from mealie.services.recipe.template_service import TemplateService
from ._base import BaseRecipeController, FormatResponse
router = UserAPIRouter(prefix="/recipes")
@controller(router)
class RecipeExportController(BaseRecipeController):
# ==================================================================================================================
# Export Operations
@router.get("/exports", response_model=FormatResponse)
def get_recipe_formats_and_templates(self):
return TemplateService().templates
@router.post("/{slug}/exports", response_model=RecipeZipTokenResponse)
def get_recipe_zip_token(self, slug: str):
"""Generates a recipe zip token to be used to download a recipe as a zip file"""
return RecipeZipTokenResponse(token=create_recipe_slug_token(slug))
@router.get("/{slug}/exports", response_class=FileResponse)
def get_recipe_as_format(self, slug: str, template_name: str):
"""
## Parameters
`template_name`: The name of the template to use to use in the exports listed. Template type will automatically
be set on the backend. Because of this, it's important that your templates have unique names. See available
names and formats in the /api/recipes/exports endpoint.
"""
with get_temporary_path(auto_unlink=False) as temp_path:
recipe = self.mixins.get_one(slug)
file = self.service.render_template(recipe, temp_path, template_name)
return FileResponse(file, background=BackgroundTask(rmtree, temp_path))
@router.get("/{slug}/exports/zip")
def get_recipe_as_zip(self, slug: str, token: str):
"""Get a Recipe and Its Original Image as a Zip File"""
with get_temporary_zip_path(auto_unlink=False) as temp_path:
validated_slug = validate_recipe_token(token)
if validated_slug != slug:
raise HTTPException(status_code=400, detail="Invalid Slug")
recipe: Recipe = self.mixins.get_one(validated_slug)
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
with ZipFile(temp_path, "w") as myzip:
myzip.writestr(f"{slug}.json", recipe.model_dump_json())
if image_asset.is_file():
myzip.write(image_asset, arcname=image_asset.name)
return FileResponse(
temp_path, filename=f"{recipe.slug}.zip", background=BackgroundTask(temp_path.unlink, missing_ok=True)
)

View File

@@ -1,8 +1,6 @@
from collections import defaultdict
from functools import cached_property
from shutil import copyfileobj, rmtree
from shutil import copyfileobj
from uuid import UUID
from zipfile import ZipFile
import orjson
import sqlalchemy
@@ -18,30 +16,19 @@ from fastapi import (
status,
)
from fastapi.datastructures import UploadFile
from fastapi.responses import JSONResponse
from pydantic import UUID4, BaseModel, Field
from pydantic import UUID4
from slugify import slugify
from starlette.background import BackgroundTask
from starlette.responses import FileResponse
from mealie.core import exceptions
from mealie.core.dependencies import (
get_temporary_path,
get_temporary_zip_path,
validate_recipe_token,
)
from mealie.core.security import create_recipe_slug_token
from mealie.db.models.household.cookbook import CookBook
from mealie.pkgs import cache
from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_generic import RepositoryGeneric
from mealie.repos.repository_recipes import RepositoryRecipes
from mealie.routes._base import BaseCrudController, controller
from mealie.routes._base.mixins import HttpRepo
from mealie.routes._base import controller
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.make_dependable import make_dependable
from mealie.schema.recipe import Recipe, RecipeImageTypes, ScrapeRecipe, ScrapeRecipeData
from mealie.schema.recipe import Recipe, ScrapeRecipe, ScrapeRecipeData
from mealie.schema.recipe.recipe import (
CreateRecipe,
CreateRecipeByUrlBulk,
@@ -50,9 +37,9 @@ from mealie.schema.recipe.recipe import (
)
from mealie.schema.recipe.recipe_asset import RecipeAsset
from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest
from mealie.schema.recipe.recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponse
from mealie.schema.recipe.request_helpers import (
RecipeDuplicate,
RecipeZipTokenResponse,
UpdateImageResponse,
)
from mealie.schema.response import PaginationBase, PaginationQuery
@@ -71,8 +58,6 @@ from mealie.services.recipe.recipe_data_service import (
NotAnImageError,
RecipeDataService,
)
from mealie.services.recipe.recipe_service import RecipeService
from mealie.services.recipe.template_service import TemplateService
from mealie.services.scraper.recipe_bulk_scraper import RecipeBulkScraperService
from mealie.services.scraper.scraped_extras import ScraperContext
from mealie.services.scraper.scraper import create_from_html
@@ -82,99 +67,7 @@ from mealie.services.scraper.scraper_strategies import (
RecipeScraperPackage,
)
class JSONBytes(JSONResponse):
"""
JSONBytes overrides the render method to return the bytes instead of a string.
You can use this when you want to use orjson and bypass the jsonable_encoder
"""
media_type = "application/json"
def render(self, content: bytes) -> bytes:
return content
class BaseRecipeController(BaseCrudController):
@cached_property
def recipes(self) -> RepositoryRecipes:
return self.repos.recipes
@cached_property
def group_recipes(self) -> RepositoryRecipes:
return get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
@cached_property
def group_cookbooks(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
return get_repositories(self.session, group_id=self.group_id, household_id=None).cookbooks
@cached_property
def service(self) -> RecipeService:
return RecipeService(self.repos, self.user, self.household, translator=self.translator)
@cached_property
def mixins(self):
return HttpRepo[CreateRecipe, Recipe, Recipe](self.recipes, self.logger)
class FormatResponse(BaseModel):
jjson: list[str] = Field(..., alias="json")
zip: list[str]
jinja2: list[str]
router_exports = UserAPIRouter(prefix="/recipes")
@controller(router_exports)
class RecipeExportController(BaseRecipeController):
# ==================================================================================================================
# Export Operations
@router_exports.get("/exports", response_model=FormatResponse)
def get_recipe_formats_and_templates(self):
return TemplateService().templates
@router_exports.post("/{slug}/exports", response_model=RecipeZipTokenResponse)
def get_recipe_zip_token(self, slug: str):
"""Generates a recipe zip token to be used to download a recipe as a zip file"""
return RecipeZipTokenResponse(token=create_recipe_slug_token(slug))
@router_exports.get("/{slug}/exports", response_class=FileResponse)
def get_recipe_as_format(self, slug: str, template_name: str):
"""
## Parameters
`template_name`: The name of the template to use to use in the exports listed. Template type will automatically
be set on the backend. Because of this, it's important that your templates have unique names. See available
names and formats in the /api/recipes/exports endpoint.
"""
with get_temporary_path(auto_unlink=False) as temp_path:
recipe = self.mixins.get_one(slug)
file = self.service.render_template(recipe, temp_path, template_name)
return FileResponse(file, background=BackgroundTask(rmtree, temp_path))
@router_exports.get("/{slug}/exports/zip")
def get_recipe_as_zip(self, slug: str, token: str):
"""Get a Recipe and Its Original Image as a Zip File"""
with get_temporary_zip_path(auto_unlink=False) as temp_path:
validated_slug = validate_recipe_token(token)
if validated_slug != slug:
raise HTTPException(status_code=400, detail="Invalid Slug")
recipe: Recipe = self.mixins.get_one(validated_slug)
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
with ZipFile(temp_path, "w") as myzip:
myzip.writestr(f"{slug}.json", recipe.model_dump_json())
if image_asset.is_file():
myzip.write(image_asset, arcname=image_asset.name)
return FileResponse(
temp_path, filename=f"{recipe.slug}.zip", background=BackgroundTask(temp_path.unlink, missing_ok=True)
)
from ._base import BaseRecipeController, JSONBytes
router = UserAPIRouter(prefix="/recipes", route_class=MealieCrudRoute)
@@ -388,6 +281,20 @@ class RecipeController(BaseRecipeController):
# Response is returned directly, to avoid validation and improve performance
return JSONBytes(content=json_compatible_response)
@router.get("/suggestions", response_model=RecipeSuggestionResponse)
def suggest_recipes(
self,
q: RecipeSuggestionQuery = Depends(make_dependable(RecipeSuggestionQuery)),
foods: list[UUID4] | None = Query(None),
tools: list[UUID4] | None = Query(None),
) -> RecipeSuggestionResponse:
recipes = self.group_recipes.find_suggested_recipes(q, foods, tools)
response = RecipeSuggestionResponse(items=recipes)
json_compatible_response = orjson.dumps(response.model_dump(by_alias=True))
# Response is returned directly, to avoid validation and improve performance
return JSONBytes(content=json_compatible_response)
@router.get("/{slug}", response_model=Recipe)
def get_one(self, slug: str = Path(..., description="A recipe's slug or id")):
"""Takes in a recipe's slug or id and returns all data for a recipe"""

View File

@@ -22,10 +22,10 @@ from mealie.services import urls
from mealie.services.event_bus_service.event_types import EventOperation, EventRecipeTimelineEventData, EventTypes
from mealie.services.recipe.recipe_data_service import RecipeDataService
events_router = UserAPIRouter(route_class=MealieCrudRoute, prefix="/timeline/events")
router = UserAPIRouter(route_class=MealieCrudRoute, prefix="/timeline/events")
@controller(events_router)
@controller(router)
class RecipeTimelineEventsController(BaseCrudController):
@cached_property
def repo(self):
@@ -43,17 +43,17 @@ class RecipeTimelineEventsController(BaseCrudController):
self.registered_exceptions,
)
@events_router.get("", response_model=RecipeTimelineEventPagination)
@router.get("", response_model=RecipeTimelineEventPagination)
def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
response = self.repo.page_all(
pagination=q,
override=RecipeTimelineEventOut,
)
response.set_pagination_guides(events_router.url_path_for("get_all"), q.model_dump())
response.set_pagination_guides(router.url_path_for("get_all"), q.model_dump())
return response
@events_router.post("", response_model=RecipeTimelineEventOut, status_code=201)
@router.post("", response_model=RecipeTimelineEventOut, status_code=201)
def create_one(self, data: RecipeTimelineEventIn):
# if the user id is not specified, use the currently-authenticated user
data.user_id = data.user_id or self.user.id
@@ -81,11 +81,11 @@ class RecipeTimelineEventsController(BaseCrudController):
return event
@events_router.get("/{item_id}", response_model=RecipeTimelineEventOut)
@router.get("/{item_id}", response_model=RecipeTimelineEventOut)
def get_one(self, item_id: UUID4):
return self.mixins.get_one(item_id)
@events_router.put("/{item_id}", response_model=RecipeTimelineEventOut)
@router.put("/{item_id}", response_model=RecipeTimelineEventOut)
def update_one(self, item_id: UUID4, data: RecipeTimelineEventUpdate):
event = self.mixins.patch_one(data, item_id)
recipe = self.group_recipes.get_one(event.recipe_id, "id")
@@ -106,7 +106,7 @@ class RecipeTimelineEventsController(BaseCrudController):
return event
@events_router.delete("/{item_id}", response_model=RecipeTimelineEventOut)
@router.delete("/{item_id}", response_model=RecipeTimelineEventOut)
def delete_one(self, item_id: UUID4):
event = self.mixins.delete_one(item_id)
if event.image_dir.exists():
@@ -136,7 +136,7 @@ class RecipeTimelineEventsController(BaseCrudController):
# ==================================================================================================================
# Image and Assets
@events_router.put("/{item_id}/image", response_model=UpdateImageResponse)
@router.put("/{item_id}/image", response_model=UpdateImageResponse)
def update_event_image(self, item_id: UUID4, image: bytes = File(...), extension: str = Form(...)):
event = self.mixins.get_one(item_id)
data_service = RecipeDataService(event.recipe_id)

View File

@@ -75,6 +75,7 @@ from .recipe_scraper import ScrapeRecipe, ScrapeRecipeBase, ScrapeRecipeData, Sc
from .recipe_settings import RecipeSettings
from .recipe_share_token import RecipeShareToken, RecipeShareTokenCreate, RecipeShareTokenSave, RecipeShareTokenSummary
from .recipe_step import IngredientReferences, RecipeStep
from .recipe_suggestion import RecipeSuggestionQuery, RecipeSuggestionResponse, RecipeSuggestionResponseItem
from .recipe_timeline_events import (
RecipeTimelineEventCreate,
RecipeTimelineEventIn,
@@ -109,6 +110,9 @@ __all__ = [
"RecipeTimelineEventUpdate",
"TimelineEventImage",
"TimelineEventType",
"RecipeSuggestionQuery",
"RecipeSuggestionResponse",
"RecipeSuggestionResponseItem",
"Nutrition",
"RecipeShareToken",
"RecipeShareTokenCreate",

View File

@@ -0,0 +1,24 @@
from mealie.schema._mealie.mealie_model import MealieModel
from mealie.schema.recipe.recipe import RecipeSummary, RecipeTool
from mealie.schema.recipe.recipe_ingredient import IngredientFood
from mealie.schema.response.pagination import RequestQuery
class RecipeSuggestionQuery(RequestQuery):
limit: int = 10
max_missing_foods: int = 5
max_missing_tools: int = 5
include_foods_on_hand: bool = True
include_tools_on_hand: bool = True
class RecipeSuggestionResponseItem(MealieModel):
recipe: RecipeSummary
missing_foods: list[IngredientFood]
missing_tools: list[RecipeTool]
class RecipeSuggestionResponse(MealieModel):
items: list[RecipeSuggestionResponseItem]

View File

@@ -1,5 +1,12 @@
# This file is auto-generated by gen_schema_exports.py
from .pagination import OrderByNullPosition, OrderDirection, PaginationBase, PaginationQuery, RecipeSearchQuery
from .pagination import (
OrderByNullPosition,
OrderDirection,
PaginationBase,
PaginationQuery,
RecipeSearchQuery,
RequestQuery,
)
from .query_filter import (
LogicalOperator,
QueryFilterBuilder,
@@ -27,6 +34,7 @@ __all__ = [
"PaginationBase",
"PaginationQuery",
"RecipeSearchQuery",
"RequestQuery",
"SearchFilter",
"ErrorResponse",
"FileTokenResponse",

View File

@@ -31,9 +31,7 @@ class RecipeSearchQuery(MealieModel):
_search_seed: str | None = None
class PaginationQuery(MealieModel):
page: int = 1
per_page: int = 50
class RequestQuery(MealieModel):
order_by: str | None = None
order_by_null_position: OrderByNullPosition | None = None
order_direction: OrderDirection = OrderDirection.desc
@@ -47,6 +45,11 @@ class PaginationQuery(MealieModel):
return pagination_seed
class PaginationQuery(RequestQuery):
page: int = 1
per_page: int = 50
class PaginationBase(BaseModel, Generic[DataT]):
page: int = 1
per_page: int = 10