Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Cross-Household Recipes #4089

Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 7 additions & 14 deletions frontend/components/Domain/Recipe/RecipeActionMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,31 +21,23 @@

<v-spacer></v-spacer>
<div v-if="!open" class="custom-btn-group ma-1">
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :recipe-id="recipe.id" show-always />
<RecipeTimelineBadge v-if="loggedIn" button-style :slug="recipe.slug" :recipe-name="recipe.name" />
<RecipeFavoriteBadge v-if="loggedIn" class="ml-1" color="info" button-style :recipe-id="recipe.id" show-always />
<RecipeTimelineBadge v-if="loggedIn" button-style class="ml-1" :slug="recipe.slug" :recipe-name="recipe.name" />
<div v-if="loggedIn">
<v-tooltip v-if="!locked" bottom color="info">
<v-tooltip v-if="canEdit" bottom color="info">
<template #activator="{ on, attrs }">
<v-btn fab small class="mx-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)">
<v-btn fab small class="ml-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)">
<v-icon> {{ $globals.icons.edit }} </v-icon>
</v-btn>
</template>
<span>{{ $t("general.edit") }}</span>
</v-tooltip>
<v-tooltip v-else bottom color="info">
<template #activator="{ on, attrs }">
<v-btn fab small class="mx-1" color="info" v-bind="attrs" v-on="on">
<v-icon> {{ $globals.icons.lock }} </v-icon>
</v-btn>
</template>
<span> {{ $t("recipe.locked-by-owner") }} </span>
</v-tooltip>
</div>

<RecipeTimerMenu
fab
color="info"
class="mr-1"
class="ml-1"
/>

<RecipeContextMenu
Expand All @@ -72,6 +64,7 @@
share: loggedIn,
recipeActions: true,
}"
class="ml-1"
@print="$emit('print')"
/>
</div>
Expand Down Expand Up @@ -135,7 +128,7 @@ export default defineComponent({
required: true,
type: String,
},
locked: {
canEdit: {
type: Boolean,
default: false,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
:recipe="recipe"
:slug="recipe.slug"
:recipe-scale="recipeScale"
:locked="isOwnGroup && user.id !== recipe.userId && recipe.settings.locked"
:can-edit="canEdit"
:name="recipe.name"
:logged-in="isOwnGroup"
:open="isEditMode"
Expand Down Expand Up @@ -100,6 +100,31 @@ export default defineComponent({
const { user } = usePageUser();
const { isOwnGroup } = useLoggedInState();

const canEdit = computed(() => {
// Check recipe owner
if (!user.id) {
return false;
}
if (user.id === props.recipe.userId) {
return true;
}

// Check group and household
if (!isOwnGroup.value) {
return false;
}
if (user.householdId !== props.recipe.householdId) {
return false;
}

// Check recipe
if (props.recipe.settings.locked) {
return false;
}

return true;
})
michael-genson marked this conversation as resolved.
Show resolved Hide resolved

function printRecipe() {
window.print();
}
Expand All @@ -125,6 +150,7 @@ export default defineComponent({
setMode,
toggleEditMode,
recipeImage,
canEdit,
imageKey,
user,
PageMode,
Expand Down
6 changes: 4 additions & 2 deletions frontend/composables/recipes/use-recipes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,10 @@ export const useLazyRecipes = function (publicGroupSlug: string | null = null) {
};

export const useRecipes = (
all = false, fetchRecipes = true,
all = false,
fetchRecipes = true,
loadFood = false,
queryFilter: string | null = null,
publicGroupSlug: string | null = null
) => {
const api = publicGroupSlug ? usePublicExploreApi(publicGroupSlug).explore : useUserApi();
Expand All @@ -108,7 +110,7 @@ export const useRecipes = (
})();

async function refreshRecipes() {
const { data } = await api.recipes.getAll(page, perPage, { loadFood, orderBy: "created_at" });
const { data } = await api.recipes.getAll(page, perPage, { loadFood, orderBy: "created_at", queryFilter });
if (data) {
recipes.value = data.items;
}
Expand Down
3 changes: 1 addition & 2 deletions frontend/pages/g/_groupSlug/recipes/timeline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ export default defineComponent({
async function fetchHousehold() {
const { data } = await api.households.getCurrentUserHousehold();
if (data) {
// TODO: once users are able to fetch other households' recipes, remove the household filter
queryFilter.value = `recipe.group_id="${data.groupId}" AND recipe.household_id="${data.id}"`;
queryFilter.value = `recipe.group_id="${data.groupId}"`;
groupName.value = data.group;
}

Expand Down
6 changes: 2 additions & 4 deletions frontend/pages/group/data/recipes.vue
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,8 @@ export default defineComponent({
components: { RecipeDataTable, RecipeOrganizerSelector, GroupExportData, RecipeSettingsSwitches },
scrollToTop: true,
setup() {
const { getAllRecipes, refreshRecipes } = useRecipes(true, true);

const { $globals, i18n } = useContext();

const { $auth, $globals, i18n } = useContext();
const { getAllRecipes, refreshRecipes } = useRecipes(true, true, false, `householdId=${$auth.user?.householdId || ""}`);
const selected = ref<Recipe[]>([]);

function resetAll() {
Expand Down
18 changes: 12 additions & 6 deletions mealie/routes/recipe/recipe_crud_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
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
Expand Down Expand Up @@ -94,9 +95,13 @@ def render(self, content: bytes) -> bytes:

class BaseRecipeController(BaseCrudController):
@cached_property
def repo(self) -> RepositoryRecipes:
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 cookbooks_repo(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
return self.repos.cookbooks
Expand All @@ -107,7 +112,7 @@ def service(self) -> RecipeService:

@cached_property
def mixins(self):
return HttpRepo[CreateRecipe, Recipe, Recipe](self.repo, self.logger)
return HttpRepo[CreateRecipe, Recipe, Recipe](self.recipes, self.logger)


class FormatResponse(BaseModel):
Expand Down Expand Up @@ -331,8 +336,9 @@ def get_all(
if cookbook_data is None:
raise HTTPException(status_code=404, detail="cookbook not found")

# we use the repo by user so we can sort favorites correctly
pagination_response = self.repos.recipes.by_user(self.user.id).page_all(
# We use "group_recipes" here so we can return all recipes regardless of household. The query filter can include
# a household_id to filter by household. We use the "by_user" so we can sort favorites correctly.
pagination_response = self.group_recipes.by_user(self.user.id).page_all(
pagination=q,
cookbook=cookbook_data,
categories=categories,
Expand Down Expand Up @@ -362,7 +368,7 @@ def get_all(
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"""
try:
recipe = self.service.get_one_by_slug_or_id(slug)
recipe = self.service.get_one(slug)
except Exception as e:
self.handle_exceptions(e)
return None
Expand Down Expand Up @@ -534,7 +540,7 @@ def update_recipe_image(self, slug: str, image: bytes = File(...), extension: st
data_service = RecipeDataService(recipe.id)
data_service.write_image(image, extension)

new_version = self.repo.update_image(slug, extension)
new_version = self.recipes.update_image(slug, extension)
return UpdateImageResponse(image=new_version)

@router.post("/{slug}/assets", response_model=RecipeAsset, tags=["Recipe: Images and Assets"])
Expand Down
13 changes: 7 additions & 6 deletions mealie/routes/recipe/timeline_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from fastapi import Depends, File, Form, HTTPException
from pydantic import UUID4

from mealie.repos.all_repositories import get_repositories
from mealie.routes._base import BaseCrudController, controller
from mealie.routes._base.mixins import HttpRepo
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
Expand Down Expand Up @@ -31,8 +32,8 @@ def repo(self):
return self.repos.recipe_timeline_events

@cached_property
def recipes_repo(self):
return self.repos.recipes
def group_recipes(self):
return get_repositories(self.session, group_id=self.group_id, household_id=None).recipes

@cached_property
def mixins(self):
Expand All @@ -57,7 +58,7 @@ 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

recipe = self.recipes_repo.get_one(data.recipe_id, "id")
recipe = self.group_recipes.get_one(data.recipe_id, "id")
if not recipe:
raise HTTPException(status_code=404, detail="recipe not found")

Expand Down Expand Up @@ -87,7 +88,7 @@ def get_one(self, item_id: UUID4):
@events_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.recipes_repo.get_one(event.recipe_id, "id")
recipe = self.group_recipes.get_one(event.recipe_id, "id")
if recipe:
self.publish_event(
event_type=EventTypes.recipe_updated,
Expand All @@ -114,7 +115,7 @@ def delete_one(self, item_id: UUID4):
except FileNotFoundError:
pass

recipe = self.recipes_repo.get_one(event.recipe_id, "id")
recipe = self.group_recipes.get_one(event.recipe_id, "id")
if recipe:
self.publish_event(
event_type=EventTypes.recipe_updated,
Expand Down Expand Up @@ -144,7 +145,7 @@ def update_event_image(self, item_id: UUID4, image: bytes = File(...), extension
if event.image != TimelineEventImage.has_image.value:
event.image = TimelineEventImage.has_image
event = self.mixins.patch_one(event.cast(RecipeTimelineEventUpdate), event.id)
recipe = self.recipes_repo.get_one(event.recipe_id, "id")
recipe = self.group_recipes.get_one(event.recipe_id, "id")
if recipe:
self.publish_event(
event_type=EventTypes.recipe_updated,
Expand Down
11 changes: 8 additions & 3 deletions mealie/routes/users/ratings.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from functools import cached_property
from uuid import UUID

from fastapi import HTTPException, status
from pydantic import UUID4

from mealie.repos.all_repositories import get_repositories
from mealie.routes._base import BaseUserController, controller
from mealie.routes._base.routers import UserAPIRouter
from mealie.routes.users._helpers import assert_user_change_allowed
Expand All @@ -14,6 +16,10 @@

@controller(router)
class UserRatingsController(BaseUserController):
@cached_property
def group_recipes(self):
return get_repositories(self.session, group_id=self.group_id, household_id=None).recipes

def get_recipe_or_404(self, slug_or_id: str | UUID):
"""Fetches a recipe by slug or id, or raises a 404 error if not found."""
if isinstance(slug_or_id, str):
Expand All @@ -22,11 +28,10 @@ def get_recipe_or_404(self, slug_or_id: str | UUID):
except ValueError:
pass

recipes_repo = self.repos.recipes
if isinstance(slug_or_id, UUID):
recipe = recipes_repo.get_one(slug_or_id, key="id")
recipe = self.group_recipes.get_one(slug_or_id, key="id")
else:
recipe = recipes_repo.get_one(slug_or_id, key="slug")
recipe = self.group_recipes.get_one(slug_or_id, key="slug")

if not recipe:
raise HTTPException(
Expand Down
26 changes: 21 additions & 5 deletions mealie/services/recipe/recipe_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from mealie.core.dependencies.dependencies import get_temporary_path
from mealie.lang.providers import Translator
from mealie.pkgs import cache
from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories
from mealie.repos.repository_generic import RepositoryGeneric
from mealie.schema.household.household import HouseholdInDB
Expand Down Expand Up @@ -46,6 +47,9 @@ def __init__(self, repos: AllRepositories, user: PrivateUser, household: Househo
if repos.household_id != user.household_id != household.id:
raise Exception("household ids do not match")

self.group_recipes = get_repositories(repos.session, group_id=repos.group_id, household_id=None).recipes
"""Recipes repo without a Household filter"""
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥


self.translator = translator
self.t = translator.t

Expand All @@ -54,15 +58,26 @@ def __init__(self, repos: AllRepositories, user: PrivateUser, household: Househo

class RecipeService(RecipeServiceBase):
def _get_recipe(self, data: str | UUID, key: str | None = None) -> Recipe:
recipe = self.repos.recipes.get_one(data, key)
recipe = self.group_recipes.get_one(data, key)
if recipe is None:
raise exceptions.NoEntryFound("Recipe not found.")
return recipe

def can_update(self, recipe: Recipe) -> bool:
if recipe.settings is None:
raise exceptions.UnexpectedNone("Recipe Settings is None")
return recipe.settings.locked is False or self.user.id == recipe.user_id

# Check if this user owns the recipe
if self.user.id == recipe.user_id:
return True

# Check if this user has permission to edit this recipe
if self.household.id != recipe.household_id:
return False
if recipe.settings.locked:
return False

return True

def can_lock_unlock(self, recipe: Recipe) -> bool:
return recipe.user_id == self.user.id
Expand Down Expand Up @@ -120,7 +135,7 @@ def _recipe_creation_factory(self, name: str, additional_attrs: dict | None = No

return Recipe(**additional_attrs)

def get_one_by_slug_or_id(self, slug_or_id: str | UUID) -> Recipe | None:
def get_one(self, slug_or_id: str | UUID) -> Recipe | None:
if isinstance(slug_or_id, str):
try:
slug_or_id = UUID(slug_or_id)
Expand Down Expand Up @@ -393,9 +408,10 @@ def patch_one(self, slug: str, patch_data: Recipe) -> Recipe:
return new_data

def update_last_made(self, slug: str, timestamp: datetime) -> Recipe:
# we bypass the pre update check since any user can update a recipe's last made date, even if it's locked
# we bypass the pre update check since any user can update a recipe's last made date, even if it's locked,
# or if the user belongs to a different household
recipe = self._get_recipe(slug)
return self.repos.recipes.patch(recipe.slug, {"last_made": timestamp})
return self.group_recipes.patch(recipe.slug, {"last_made": timestamp})

def delete_one(self, slug) -> Recipe:
recipe = self._get_recipe(slug)
Expand Down
17 changes: 17 additions & 0 deletions tests/integration_tests/user_recipe_tests/test_recipe_comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,20 @@ def test_admin_can_delete(
response = api_client.get(api_routes.comments_item_id(comment_id), headers=admin_user.token)

assert response.status_code == 404


def test_user_can_comment_on_other_household(api_client: TestClient, unique_recipe: Recipe, h2_user: TestUser):
# Create Comment
create_data = random_comment(unique_recipe.id)
response = api_client.post(api_routes.comments, json=create_data, headers=h2_user.token)
assert response.status_code == 201

# Delete Comment
comment_id = response.json()["id"]
response = api_client.delete(api_routes.comments_item_id(comment_id), headers=h2_user.token)
assert response.status_code == 200

# Validate Deletion
response = api_client.get(api_routes.comments_item_id(comment_id), headers=h2_user.token)

assert response.status_code == 404
Loading
Loading