mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-13 03:13:12 -05:00
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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"])
|
||||
|
||||
53
mealie/routes/explore/controller_public_cookbooks.py
Normal file
53
mealie/routes/explore/controller_public_cookbooks.py
Normal 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)
|
||||
38
mealie/routes/explore/controller_public_foods.py
Normal file
38
mealie/routes/explore/controller_public_foods.py
Normal 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
|
||||
99
mealie/routes/explore/controller_public_organizers.py
Normal file
99
mealie/routes/explore/controller_public_organizers.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user