1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-03 04:25:24 +02: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:
Michael Genson 2023-09-14 09:01:24 -05:00 committed by GitHub
parent e28b830cd4
commit 2c5e5a8421
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 2399 additions and 953 deletions

View file

@ -1,62 +0,0 @@
from dataclasses import dataclass
import pytest
from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe import Recipe
from tests.utils import api_routes
from tests.utils.fixture_schemas import TestUser
@dataclass(slots=True)
class PublicRecipeTestCase:
private_group: bool
public_recipe: bool
status_code: int
error: str | None
@pytest.mark.parametrize(
"test_case",
(
PublicRecipeTestCase(private_group=False, public_recipe=True, status_code=200, error=None),
PublicRecipeTestCase(private_group=True, public_recipe=True, status_code=404, error="group not found"),
PublicRecipeTestCase(private_group=False, public_recipe=False, status_code=404, error="recipe not found"),
),
ids=("is public", "group private", "recipe private"),
)
def test_public_recipe_success(
api_client: TestClient,
unique_user: TestUser,
random_recipe: Recipe,
database: AllRepositories,
test_case: PublicRecipeTestCase,
):
group = database.groups.get_one(unique_user.group_id)
assert group
group.preferences.private_group = test_case.private_group
database.group_preferences.update(group.id, group.preferences)
# Set Recipe `settings.public` attribute
random_recipe.settings.public = test_case.public_recipe
database.recipes.update(random_recipe.slug, random_recipe)
# Try to access recipe
recipe_group = database.groups.get_by_slug_or_id(random_recipe.group_id)
response = api_client.get(
api_routes.explore_recipes_group_slug_recipe_slug(
recipe_group.slug,
random_recipe.slug,
)
)
assert response.status_code == test_case.status_code
if test_case.error:
assert response.json()["detail"] == test_case.error
return
as_json = response.json()
assert as_json["name"] == random_recipe.name
assert as_json["slug"] == random_recipe.slug

View file

@ -0,0 +1,151 @@
import random
import pytest
from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.cookbook.cookbook import SaveCookBook
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_category import TagSave
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
@pytest.mark.parametrize("is_private_group", [True, False], ids=["group_is_private", "group_is_public"])
def test_get_all_cookbooks(
api_client: TestClient,
unique_user: TestUser,
database: AllRepositories,
is_private_group: bool,
):
## Set Up Group
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = is_private_group
database.group_preferences.update(group.id, group.preferences)
## Set Up Cookbooks
default_cookbooks = database.cookbooks.create_many(
[SaveCookBook(name=random_string(), group_id=unique_user.group_id) for _ in range(random_int(15, 20))]
)
random.shuffle(default_cookbooks)
split_index = random_int(6, 12)
public_cookbooks = default_cookbooks[:split_index]
private_cookbooks = default_cookbooks[split_index:]
for cookbook in public_cookbooks:
cookbook.public = True
for cookbook in private_cookbooks:
cookbook.public = False
database.cookbooks.update_many(public_cookbooks + private_cookbooks)
## Test Cookbooks
response = api_client.get(api_routes.explore_cookbooks_group_slug(unique_user.group_id))
if is_private_group:
assert response.status_code == 404
return
assert response.status_code == 200
cookbooks_data = response.json()
fetched_ids: set[str] = {cookbook["id"] for cookbook in cookbooks_data["items"]}
for cookbook in public_cookbooks:
assert str(cookbook.id) in fetched_ids
for cookbook in private_cookbooks:
assert str(cookbook.id) not in fetched_ids
@pytest.mark.parametrize(
"is_private_group, is_private_cookbook",
[
(True, True),
(True, False),
(False, True),
(False, False),
],
ids=[
"group_is_private_cookbook_is_private",
"group_is_private_cookbook_is_public",
"group_is_public_cookbook_is_private",
"group_is_public_cookbook_is_public",
],
)
def test_get_one_cookbook(
api_client: TestClient,
unique_user: TestUser,
database: AllRepositories,
is_private_group: bool,
is_private_cookbook: bool,
):
## Set Up Group
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = is_private_group
database.group_preferences.update(group.id, group.preferences)
## Set Up Cookbook
cookbook = database.cookbooks.create(
SaveCookBook(
name=random_string(),
group_id=unique_user.group_id,
public=not is_private_cookbook,
)
)
## Test Cookbook
response = api_client.get(api_routes.explore_cookbooks_group_slug_item_id(unique_user.group_id, cookbook.id))
if is_private_group or is_private_cookbook:
assert response.status_code == 404
return
assert response.status_code == 200
cookbook_data = response.json()
assert cookbook_data["id"] == str(cookbook.id)
def test_get_cookbooks_with_recipes(api_client: TestClient, unique_user: TestUser, database: AllRepositories):
# Create a public and private recipe with a known tag
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = False
database.group_preferences.update(group.id, group.preferences)
tag = database.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id))
public_recipe, private_recipe = database.recipes.create_many(
Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string()) for _ in range(2)
)
assert public_recipe.settings
public_recipe.settings.public = True
public_recipe.tags = [tag]
assert private_recipe.settings
private_recipe.settings.public = False
private_recipe.tags = [tag]
database.recipes.update_many([public_recipe, private_recipe])
# Create a public cookbook with tag
cookbook = database.cookbooks.create(
SaveCookBook(name=random_string(), group_id=unique_user.group_id, public=True, tags=[tag])
)
database.cookbooks.create(cookbook)
# Get the cookbook and make sure we only get the public recipe
response = api_client.get(api_routes.explore_cookbooks_group_slug_item_id(unique_user.group_id, cookbook.id))
assert response.status_code == 200
cookbook_data = response.json()
assert cookbook_data["id"] == str(cookbook.id)
cookbook_recipe_ids: set[str] = {recipe["id"] for recipe in cookbook_data["recipes"]}
assert len(cookbook_recipe_ids) == 1
assert str(public_recipe.id) in cookbook_recipe_ids
assert str(private_recipe.id) not in cookbook_recipe_ids

View file

@ -0,0 +1,69 @@
import pytest
from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe_ingredient import SaveIngredientFood
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
@pytest.mark.parametrize("is_private_group", [True, False], ids=["group_is_private", "group_is_public"])
def test_get_all_foods(
api_client: TestClient,
unique_user: TestUser,
database: AllRepositories,
is_private_group: bool,
):
## Set Up Group
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = is_private_group
database.group_preferences.update(group.id, group.preferences)
## Set Up Foods
foods = database.ingredient_foods.create_many(
[SaveIngredientFood(name=random_string(), group_id=unique_user.group_id) for _ in range(random_int(15, 20))]
)
## Test Foods
response = api_client.get(api_routes.explore_foods_group_slug(unique_user.group_id))
if is_private_group:
assert response.status_code == 404
return
assert response.status_code == 200
foods_data = response.json()
fetched_ids: set[str] = {food["id"] for food in foods_data["items"]}
for food in foods:
assert str(food.id) in fetched_ids
@pytest.mark.parametrize("is_private_group", [True, False], ids=["group_is_private", "group_is_public"])
def test_get_one_food(
api_client: TestClient,
unique_user: TestUser,
database: AllRepositories,
is_private_group: bool,
):
## Set Up Group
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = is_private_group
database.group_preferences.update(group.id, group.preferences)
## Set Up Food
food = database.ingredient_foods.create(SaveIngredientFood(name=random_string(), group_id=unique_user.group_id))
## Test Food
response = api_client.get(api_routes.explore_foods_group_slug_item_id(unique_user.group_id, food.id))
if is_private_group:
assert response.status_code == 404
return
assert response.status_code == 200
food_data = response.json()
assert food_data["id"] == str(food.id)

View file

@ -0,0 +1,142 @@
from enum import Enum
import pytest
from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe_category import CategorySave, TagSave
from mealie.schema.recipe.recipe_tool import RecipeToolSave
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
class OrganizerType(Enum):
categories = "categories"
tags = "tags"
tools = "tools"
@pytest.mark.parametrize(
"organizer_type, is_private_group",
[
(OrganizerType.categories, True),
(OrganizerType.categories, False),
(OrganizerType.tags, True),
(OrganizerType.tags, False),
(OrganizerType.tools, True),
(OrganizerType.tools, False),
],
ids=[
"private_group_categories",
"public_group_categories",
"private_group_tags",
"public_group_tags",
"private_group_tools",
"public_group_tools",
],
)
def test_get_all_organizers(
api_client: TestClient,
unique_user: TestUser,
database: AllRepositories,
organizer_type: OrganizerType,
is_private_group: bool,
):
## Set Up Group
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = is_private_group
database.group_preferences.update(group.id, group.preferences)
## Set Up Organizers
if organizer_type is OrganizerType.categories:
item_class = CategorySave
repo = database.categories # type: ignore
route = api_routes.explore_organizers_group_slug_categories
elif organizer_type is OrganizerType.tags:
item_class = TagSave
repo = database.tags # type: ignore
route = api_routes.explore_organizers_group_slug_tags
else:
item_class = RecipeToolSave
repo = database.tools # type: ignore
route = api_routes.explore_organizers_group_slug_tools
organizers = repo.create_many(
[item_class(name=random_string(), group_id=unique_user.group_id) for _ in range(random_int(15, 20))]
)
## Test Organizers
response = api_client.get(route(unique_user.group_id))
if is_private_group:
assert response.status_code == 404
return
assert response.status_code == 200
organizers_data = response.json()
fetched_ids: set[str] = {organizer["id"] for organizer in organizers_data["items"]}
for organizer in organizers:
assert str(organizer.id) in fetched_ids
@pytest.mark.parametrize(
"organizer_type, is_private_group",
[
(OrganizerType.categories, True),
(OrganizerType.categories, False),
(OrganizerType.tags, True),
(OrganizerType.tags, False),
(OrganizerType.tools, True),
(OrganizerType.tools, False),
],
ids=[
"private_group_category",
"public_group_category",
"private_group_tag",
"public_group_tag",
"private_group_tool",
"public_group_tool",
],
)
def test_get_one_organizer(
api_client: TestClient,
unique_user: TestUser,
database: AllRepositories,
organizer_type: OrganizerType,
is_private_group: bool,
):
## Set Up Group
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = is_private_group
database.group_preferences.update(group.id, group.preferences)
## Set Up Organizer
if organizer_type is OrganizerType.categories:
item_class = CategorySave
repo = database.categories # type: ignore
route = api_routes.explore_organizers_group_slug_categories_item_id
elif organizer_type is OrganizerType.tags:
item_class = TagSave
repo = database.tags # type: ignore
route = api_routes.explore_organizers_group_slug_tags_item_id
else:
item_class = RecipeToolSave
repo = database.tools # type: ignore
route = api_routes.explore_organizers_group_slug_tools_item_id
organizer = repo.create(item_class(name=random_string(), group_id=unique_user.group_id))
## Test Organizer
response = api_client.get(route(unique_user.group_id, organizer.id))
if is_private_group:
assert response.status_code == 404
return
assert response.status_code == 200
organizer_data = response.json()
assert organizer_data["id"] == str(organizer.id)

View file

@ -0,0 +1,167 @@
import random
from dataclasses import dataclass
from typing import Any
import pytest
from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe import Recipe
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
@dataclass(slots=True)
class PublicRecipeTestCase:
private_group: bool
public_recipe: bool
status_code: int
error: str | None
@pytest.mark.parametrize("is_private_group", [True, False], ids=["group_is_private", "group_is_public"])
def test_get_all_public_recipes(
api_client: TestClient,
unique_user: TestUser,
database: AllRepositories,
is_private_group: bool,
):
## Set Up Public and Private Recipes
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = is_private_group
database.group_preferences.update(group.id, group.preferences)
default_recipes = database.recipes.create_many(
[
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=random_string(),
)
for _ in range(random_int(15, 20))
],
)
random.shuffle(default_recipes)
split_index = random_int(6, 12)
public_recipes = default_recipes[:split_index]
private_recipes = default_recipes[split_index:]
for recipe in public_recipes:
assert recipe.settings
recipe.settings.public = True
for recipe in private_recipes:
assert recipe.settings
recipe.settings.public = False
database.recipes.update_many(public_recipes + private_recipes)
## Query All Recipes
response = api_client.get(api_routes.explore_recipes_group_slug(group.slug))
if is_private_group:
assert response.status_code == 404
return
assert response.status_code == 200
recipes_data = response.json()
fetched_ids: set[str] = {recipe["id"] for recipe in recipes_data["items"]}
for recipe in public_recipes:
assert str(recipe.id) in fetched_ids
for recipe in private_recipes:
assert str(recipe.id) not in fetched_ids
@pytest.mark.parametrize(
"query_filter, recipe_data, should_fetch",
[
('slug = "mypublicslug"', {"slug": "mypublicslug"}, True),
('slug = "mypublicslug"', {"slug": "notmypublicslug"}, False),
("settings.public = FALSE", {}, False),
("settings.public <> TRUE", {}, False),
],
ids=[
"match_slug",
"not_match_slug",
"bypass_public_filter_1",
"bypass_public_filter_2",
],
)
def test_get_all_public_recipes_filtered(
api_client: TestClient,
unique_user: TestUser,
random_recipe: Recipe,
database: AllRepositories,
query_filter: str,
recipe_data: dict[str, Any],
should_fetch: bool,
):
## Set Up Recipe
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = False
database.group_preferences.update(group.id, group.preferences)
assert random_recipe.settings
random_recipe.settings.public = True
database.recipes.update(random_recipe.slug, random_recipe.dict() | recipe_data)
## Query All Recipes
response = api_client.get(api_routes.explore_recipes_group_slug(group.slug), params={"queryFilter": query_filter})
assert response.status_code == 200
recipes_data = response.json()
fetched_ids: set[str] = {recipe["id"] for recipe in recipes_data["items"]}
assert should_fetch is (str(random_recipe.id) in fetched_ids)
@pytest.mark.parametrize(
"test_case",
(
PublicRecipeTestCase(private_group=False, public_recipe=True, status_code=200, error=None),
PublicRecipeTestCase(private_group=True, public_recipe=True, status_code=404, error="group not found"),
PublicRecipeTestCase(private_group=False, public_recipe=False, status_code=404, error="recipe not found"),
),
ids=("is public", "group private", "recipe private"),
)
def test_public_recipe_success(
api_client: TestClient,
unique_user: TestUser,
random_recipe: Recipe,
database: AllRepositories,
test_case: PublicRecipeTestCase,
):
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = test_case.private_group
database.group_preferences.update(group.id, group.preferences)
# Set Recipe `settings.public` attribute
assert random_recipe.settings
random_recipe.settings.public = test_case.public_recipe
database.recipes.update(random_recipe.slug, random_recipe)
# Try to access recipe
recipe_group = database.groups.get_by_slug_or_id(random_recipe.group_id)
assert recipe_group
response = api_client.get(
api_routes.explore_recipes_group_slug_recipe_slug(
recipe_group.slug,
random_recipe.slug,
)
)
assert response.status_code == test_case.status_code
if test_case.error:
assert response.json()["detail"] == test_case.error
return
as_json = response.json()
assert as_json["name"] == random_recipe.name
assert as_json["slug"] == random_recipe.slug

View file

@ -225,6 +225,61 @@ def comments_item_id(item_id):
return f"{prefix}/comments/{item_id}"
def explore_cookbooks_group_slug(group_slug):
"""`/api/explore/cookbooks/{group_slug}`"""
return f"{prefix}/explore/cookbooks/{group_slug}"
def explore_cookbooks_group_slug_item_id(group_slug, item_id):
"""`/api/explore/cookbooks/{group_slug}/{item_id}`"""
return f"{prefix}/explore/cookbooks/{group_slug}/{item_id}"
def explore_foods_group_slug(group_slug):
"""`/api/explore/foods/{group_slug}`"""
return f"{prefix}/explore/foods/{group_slug}"
def explore_foods_group_slug_item_id(group_slug, item_id):
"""`/api/explore/foods/{group_slug}/{item_id}`"""
return f"{prefix}/explore/foods/{group_slug}/{item_id}"
def explore_organizers_group_slug_categories(group_slug):
"""`/api/explore/organizers/{group_slug}/categories`"""
return f"{prefix}/explore/organizers/{group_slug}/categories"
def explore_organizers_group_slug_categories_item_id(group_slug, item_id):
"""`/api/explore/organizers/{group_slug}/categories/{item_id}`"""
return f"{prefix}/explore/organizers/{group_slug}/categories/{item_id}"
def explore_organizers_group_slug_tags(group_slug):
"""`/api/explore/organizers/{group_slug}/tags`"""
return f"{prefix}/explore/organizers/{group_slug}/tags"
def explore_organizers_group_slug_tags_item_id(group_slug, item_id):
"""`/api/explore/organizers/{group_slug}/tags/{item_id}`"""
return f"{prefix}/explore/organizers/{group_slug}/tags/{item_id}"
def explore_organizers_group_slug_tools(group_slug):
"""`/api/explore/organizers/{group_slug}/tools`"""
return f"{prefix}/explore/organizers/{group_slug}/tools"
def explore_organizers_group_slug_tools_item_id(group_slug, item_id):
"""`/api/explore/organizers/{group_slug}/tools/{item_id}`"""
return f"{prefix}/explore/organizers/{group_slug}/tools/{item_id}"
def explore_recipes_group_slug(group_slug):
"""`/api/explore/recipes/{group_slug}`"""
return f"{prefix}/explore/recipes/{group_slug}"
def explore_recipes_group_slug_recipe_slug(group_slug, recipe_slug):
"""`/api/explore/recipes/{group_slug}/{recipe_slug}`"""
return f"{prefix}/explore/recipes/{group_slug}/{recipe_slug}"