mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-09 03:45:15 -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
57
mealie/routes/recipe/_base.py
Normal file
57
mealie/routes/recipe/_base.py
Normal 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)
|
||||
76
mealie/routes/recipe/exports.py
Normal file
76
mealie/routes/recipe/exports.py
Normal 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)
|
||||
)
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
24
mealie/schema/recipe/recipe_suggestion.py
Normal file
24
mealie/schema/recipe/recipe_suggestion.py
Normal 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]
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user