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

@@ -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)