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