feat: Public Recipe Browser (#2525)

* fixed incorrect var ref

* added public recipe pagination route

* refactored frontend public/explore API

* fixed broken public cards

* hid context menu from cards when public

* fixed public app header

* fixed random recipe

* added public food, category, tag, and tool routes

* not sure why I thought that would work

* added public organizer/foods stores

* disabled clicking on tags/categories

* added public link to profile page

* linting

* force a 404 if the group slug is missing or invalid

* oops

* refactored to fit sidebar into explore

* fixed invalid logic for app header

* removed most sidebar options from public

* added backend routes for public cookbooks

* added explore cookbook pages/apis

* codegen

* added backend tests

* lint

* fixes v-for keys

* I do not understand but sure why not

---------

Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com>
This commit is contained in:
Michael Genson
2023-09-14 09:01:24 -05:00
committed by GitHub
parent e28b830cd4
commit 2c5e5a8421
55 changed files with 2399 additions and 953 deletions

View File

@@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator, Callable, Generator
from pathlib import Path
from uuid import uuid4
import fastapi
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
@@ -13,7 +14,7 @@ from mealie.core.config import get_app_dirs, get_app_settings
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.schema.user import PrivateUser, TokenData
from mealie.schema.user.user import DEFAULT_INTEGRATION_ID
from mealie.schema.user.user import DEFAULT_INTEGRATION_ID, GroupInDB
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
oauth2_scheme_soft_fail = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False)
@@ -53,6 +54,16 @@ async def is_logged_in(token: str = Depends(oauth2_scheme_soft_fail), session=De
return False
async def get_public_group(group_slug: str = fastapi.Path(...), session=Depends(generate_session)) -> GroupInDB:
repos = get_repositories(session)
group = repos.groups.get_by_slug_or_id(group_slug)
if not group or group.preferences.private_group:
raise HTTPException(404, "group not found")
else:
return group
async def get_current_user(token: str = Depends(oauth2_scheme), session=Depends(generate_session)) -> PrivateUser:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,

View File

@@ -6,7 +6,7 @@ from pydantic import UUID4
from sqlalchemy.orm import Session
from mealie.core.config import get_app_dirs, get_app_settings
from mealie.core.dependencies.dependencies import get_admin_user, get_current_user, get_integration_id
from mealie.core.dependencies.dependencies import get_admin_user, get_current_user, get_integration_id, get_public_group
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.core.root_logger import get_logger
from mealie.core.settings.directories import AppDirectories
@@ -72,6 +72,16 @@ class BasePublicController(_BaseController):
...
class BasePublicExploreController(BasePublicController):
"""
This is a public class for all User restricted controllers in the API.
It includes the common SharedDependencies and some common methods used
by all Admin controllers.
"""
group: GroupInDB = Depends(get_public_group)
class BaseUserController(_BaseController):
"""
This is a base class for all User restricted controllers in the API.

View File

@@ -1,7 +1,19 @@
from fastapi import APIRouter
from . import controller_public_recipes
from . import (
controller_public_cookbooks,
controller_public_foods,
controller_public_organizers,
controller_public_recipes,
)
prefix = "/explore"
router = APIRouter()
router.include_router(controller_public_recipes.router)
router.include_router(controller_public_cookbooks.router, prefix=prefix, tags=["Explore: Cookbooks"])
router.include_router(controller_public_foods.router, prefix=prefix, tags=["Explore: Foods"])
router.include_router(controller_public_organizers.categories_router, prefix=prefix, tags=["Explore: Categories"])
router.include_router(controller_public_organizers.tags_router, prefix=prefix, tags=["Explore: Tags"])
router.include_router(controller_public_organizers.tools_router, prefix=prefix, tags=["Explore: Tools"])
router.include_router(controller_public_recipes.router, prefix=prefix, tags=["Explore: Recipes"])

View File

@@ -0,0 +1,53 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4
from mealie.routes._base import controller
from mealie.routes._base.base_controllers import BasePublicExploreController
from mealie.schema.cookbook.cookbook import ReadCookBook, RecipeCookBook
from mealie.schema.make_dependable import make_dependable
from mealie.schema.response.pagination import PaginationBase, PaginationQuery
router = APIRouter(prefix="/cookbooks/{group_slug}")
@controller(router)
class PublicCookbooksController(BasePublicExploreController):
@property
def cookbooks(self):
return self.repos.cookbooks.by_group(self.group.id)
@property
def recipes(self):
return self.repos.recipes.by_group(self.group.id)
@router.get("", response_model=PaginationBase[ReadCookBook])
def get_all(
self, q: PaginationQuery = Depends(make_dependable(PaginationQuery)), search: str | None = None
) -> PaginationBase[ReadCookBook]:
public_filter = "public = TRUE"
if q.query_filter:
q.query_filter = f"({q.query_filter}) AND {public_filter}"
else:
q.query_filter = public_filter
response = self.cookbooks.page_all(
pagination=q,
override=ReadCookBook,
search=search,
)
response.set_pagination_guides(router.url_path_for("get_all", group_slug=self.group.slug), q.dict())
return response
@router.get("/{item_id}", response_model=RecipeCookBook)
def get_one(self, item_id: UUID4 | str) -> RecipeCookBook:
match_attr = "slug" if isinstance(item_id, str) else "id"
cookbook = self.cookbooks.get_one(item_id, match_attr)
if not cookbook or not cookbook.public:
raise HTTPException(404, "cookbook not found")
recipes = self.recipes.page_all(
PaginationQuery(page=1, per_page=-1, query_filter="settings.public = TRUE"), cookbook=cookbook
)
return cookbook.cast(RecipeCookBook, recipes=recipes.items)

View File

@@ -0,0 +1,38 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4
from mealie.routes._base import controller
from mealie.routes._base.base_controllers import BasePublicExploreController
from mealie.schema.make_dependable import make_dependable
from mealie.schema.recipe.recipe_ingredient import IngredientFood
from mealie.schema.response.pagination import PaginationBase, PaginationQuery
router = APIRouter(prefix="/foods/{group_slug}")
@controller(router)
class PublicFoodsController(BasePublicExploreController):
@property
def ingredient_foods(self):
return self.repos.ingredient_foods.by_group(self.group.id)
@router.get("", response_model=PaginationBase[IngredientFood])
def get_all(
self, q: PaginationQuery = Depends(make_dependable(PaginationQuery)), search: str | None = None
) -> PaginationBase[IngredientFood]:
response = self.ingredient_foods.page_all(
pagination=q,
override=IngredientFood,
search=search,
)
response.set_pagination_guides(router.url_path_for("get_all", group_slug=self.group.slug), q.dict())
return response
@router.get("/{item_id}", response_model=IngredientFood)
def get_one(self, item_id: UUID4) -> IngredientFood:
item = self.ingredient_foods.get_one(item_id)
if not item:
raise HTTPException(404, "food not found")
return item

View File

@@ -0,0 +1,99 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4
from mealie.routes._base import controller
from mealie.routes._base.base_controllers import BasePublicExploreController
from mealie.schema.make_dependable import make_dependable
from mealie.schema.recipe.recipe import RecipeCategory, RecipeTag, RecipeTool
from mealie.schema.recipe.recipe_category import CategoryOut, TagOut
from mealie.schema.recipe.recipe_tool import RecipeToolOut
from mealie.schema.response.pagination import PaginationBase, PaginationQuery
base_prefix = "/organizers/{group_slug}"
categories_router = APIRouter(prefix=f"{base_prefix}/categories")
tags_router = APIRouter(prefix=f"{base_prefix}/tags")
tools_router = APIRouter(prefix=f"{base_prefix}/tools")
@controller(categories_router)
class PublicCategoriesController(BasePublicExploreController):
@property
def categories(self):
return self.repos.categories.by_group(self.group.id)
@categories_router.get("", response_model=PaginationBase[RecipeCategory])
def get_all(
self, q: PaginationQuery = Depends(make_dependable(PaginationQuery)), search: str | None = None
) -> PaginationBase[RecipeCategory]:
response = self.categories.page_all(
pagination=q,
override=RecipeCategory,
search=search,
)
response.set_pagination_guides(categories_router.url_path_for("get_all", group_slug=self.group.slug), q.dict())
return response
@categories_router.get("/{item_id}", response_model=CategoryOut)
def get_one(self, item_id: UUID4) -> CategoryOut:
item = self.categories.get_one(item_id)
if not item:
raise HTTPException(404, "category not found")
return item
@controller(tags_router)
class PublicTagsController(BasePublicExploreController):
@property
def tags(self):
return self.repos.tags.by_group(self.group.id)
@tags_router.get("", response_model=PaginationBase[RecipeTag])
def get_all(
self, q: PaginationQuery = Depends(make_dependable(PaginationQuery)), search: str | None = None
) -> PaginationBase[RecipeTag]:
response = self.tags.page_all(
pagination=q,
override=RecipeTag,
search=search,
)
response.set_pagination_guides(tags_router.url_path_for("get_all", group_slug=self.group.slug), q.dict())
return response
@tags_router.get("/{item_id}", response_model=TagOut)
def get_one(self, item_id: UUID4) -> TagOut:
item = self.tags.get_one(item_id)
if not item:
raise HTTPException(404, "tag not found")
return item
@controller(tools_router)
class PublicToolsController(BasePublicExploreController):
@property
def tools(self):
return self.repos.tools.by_group(self.group.id)
@tools_router.get("", response_model=PaginationBase[RecipeTool])
def get_all(
self, q: PaginationQuery = Depends(make_dependable(PaginationQuery)), search: str | None = None
) -> PaginationBase[RecipeTool]:
response = self.tools.page_all(
pagination=q,
override=RecipeTool,
search=search,
)
response.set_pagination_guides(tools_router.url_path_for("get_all", group_slug=self.group.slug), q.dict())
return response
@tools_router.get("/{item_id}", response_model=RecipeToolOut)
def get_one(self, item_id: UUID4) -> RecipeToolOut:
item = self.tools.get_one(item_id)
if not item:
raise HTTPException(404, "tool not found")
return item

View File

@@ -1,22 +1,83 @@
from fastapi import APIRouter, HTTPException
import orjson
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import UUID4
from mealie.routes._base import controller
from mealie.routes._base.base_controllers import BasePublicController
from mealie.routes._base.base_controllers import BasePublicExploreController
from mealie.routes.recipe.recipe_crud_routes import JSONBytes
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.response.pagination import PaginationBase, PaginationQuery, RecipeSearchQuery
router = APIRouter(prefix="/explore", tags=["Explore: Recipes"])
router = APIRouter(prefix="/recipes/{group_slug}")
@controller(router)
class PublicRecipesController(BasePublicController):
@router.get("/recipes/{group_slug}/{recipe_slug}", response_model=Recipe)
def get_recipe(self, group_slug: str, recipe_slug: str) -> Recipe:
group = self.repos.groups.get_by_slug_or_id(group_slug)
class PublicRecipesController(BasePublicExploreController):
@property
def cookbooks(self):
return self.repos.cookbooks.by_group(self.group.id)
if not group or group.preferences.private_group:
raise HTTPException(404, "group not found")
@property
def recipes(self):
return self.repos.recipes.by_group(self.group.id)
recipe = self.repos.recipes.by_group(group.id).get_one(recipe_slug)
@router.get("", response_model=PaginationBase[RecipeSummary])
def get_all(
self,
request: Request,
q: PaginationQuery = Depends(make_dependable(PaginationQuery)),
search_query: RecipeSearchQuery = Depends(make_dependable(RecipeSearchQuery)),
categories: list[UUID4 | str] | None = Query(None),
tags: list[UUID4 | str] | None = Query(None),
tools: list[UUID4 | str] | None = Query(None),
foods: list[UUID4 | str] | None = Query(None),
) -> PaginationBase[RecipeSummary]:
cookbook_data: ReadCookBook | None = None
if search_query.cookbook:
cb_match_attr = "slug" if isinstance(search_query.cookbook, str) else "id"
cookbook_data = self.cookbooks.get_one(search_query.cookbook, cb_match_attr)
if cookbook_data is None or not cookbook_data.public:
raise HTTPException(status_code=404, detail="cookbook not found")
public_filter = "settings.public = TRUE"
if q.query_filter:
q.query_filter = f"({q.query_filter}) AND {public_filter}"
else:
q.query_filter = public_filter
pagination_response = self.recipes.page_all(
pagination=q,
cookbook=cookbook_data,
categories=categories,
tags=tags,
tools=tools,
foods=foods,
require_all_categories=search_query.require_all_categories,
require_all_tags=search_query.require_all_tags,
require_all_tools=search_query.require_all_tools,
require_all_foods=search_query.require_all_foods,
search=search_query.search,
)
# merge default pagination with the request's query params
query_params = q.dict() | {**request.query_params}
pagination_response.set_pagination_guides(
router.url_path_for("get_all", group_slug=self.group.slug),
{k: v for k, v in query_params.items() if v is not None},
)
json_compatible_response = orjson.dumps(pagination_response.dict(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 = self.repos.recipes.by_group(self.group.id).get_one(recipe_slug)
if not recipe or not recipe.settings.public:
raise HTTPException(404, "recipe not found")

View File

@@ -250,7 +250,7 @@ class RecipeController(BaseRecipeController):
cb_match_attr = "slug" if isinstance(search_query.cookbook, str) else "id"
cookbook_data = self.cookbooks_repo.get_one(search_query.cookbook, cb_match_attr)
if search_query.cookbook is None:
if cookbook_data is None:
raise HTTPException(status_code=404, detail="cookbook not found")
pagination_response = self.repo.page_all(