Skip to content

Commit

Permalink
feat: Cross-Household Recipes (#4089)
Browse files Browse the repository at this point in the history
  • Loading branch information
michael-genson authored Sep 1, 2024
1 parent 7ef2e91 commit 9acf9ec
Show file tree
Hide file tree
Showing 16 changed files with 545 additions and 92 deletions.
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="canEditRecipe"
:name="recipe.name"
:logged-in="isOwnGroup"
:open="isEditMode"
Expand All @@ -64,6 +64,7 @@
<script lang="ts">
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useRecipePermissions } from "~/composables/recipes";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
Expand Down Expand Up @@ -99,6 +100,7 @@ export default defineComponent({
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
const { user } = usePageUser();
const { isOwnGroup } = useLoggedInState();
const { canEditRecipe } = useRecipePermissions(props.recipe, user);
function printRecipe() {
window.print();
Expand All @@ -125,6 +127,7 @@ export default defineComponent({
setMode,
toggleEditMode,
recipeImage,
canEditRecipe,
imageKey,
user,
PageMode,
Expand Down
1 change: 1 addition & 0 deletions frontend/composables/recipes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-rec
export { parseIngredientText, useParsedIngredientText } from "./use-recipe-ingredients";
export { useNutritionLabels } from "./use-recipe-nutrition";
export { useTools } from "./use-recipe-tools";
export { useRecipePermissions } from "./use-recipe-permissions";
81 changes: 81 additions & 0 deletions frontend/composables/recipes/use-recipe-permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, test, expect } from "vitest";
import { useRecipePermissions } from "./use-recipe-permissions";
import { Recipe } from "~/lib/api/types/recipe";
import { UserOut } from "~/lib/api/types/user";

describe("test use recipe permissions", () => {
const commonUserId = "my-user-id";
const commonGroupId = "my-group-id";
const commonHouseholdId = "my-household-id";

const createRecipe = (overrides: Partial<Recipe>, isLocked = false): Recipe => ({
id: "my-recipe-id",
userId: commonUserId,
groupId: commonGroupId,
householdId: commonHouseholdId,
settings: {
locked: isLocked,
},
...overrides,
});

const createUser = (overrides: Partial<UserOut>): UserOut => ({
id: commonUserId,
groupId: commonGroupId,
groupSlug: "my-group",
group: "my-group",
householdId: commonHouseholdId,
householdSlug: "my-household",
household: "my-household",
email: "[email protected]",
cacheKey: "1234",
...overrides,
});

test("when user is null, cannot edit", () => {
const result = useRecipePermissions(createRecipe({}), null);
expect(result.canEditRecipe.value).toBe(false);
});

test("when user is recipe owner, can edit", () => {
const result = useRecipePermissions(createRecipe({}), createUser({}));
expect(result.canEditRecipe.value).toBe(true);
});

test("when user is not recipe owner, is correct group and household, and recipe is unlocked, can edit", () => {
const result = useRecipePermissions(
createRecipe({}),
createUser({ id: "other-user-id" }),
);
expect(result.canEditRecipe.value).toBe(true);
});

test("when user is not recipe owner, and user is other group, cannot edit", () => {
const result = useRecipePermissions(
createRecipe({}),
createUser({ id: "other-user-id", groupId: "other-group-id"}),
);
expect(result.canEditRecipe.value).toBe(false);
});

test("when user is not recipe owner, and user is other household, cannot edit", () => {
const result = useRecipePermissions(
createRecipe({}),
createUser({ id: "other-user-id", householdId: "other-household-id" }),
);
expect(result.canEditRecipe.value).toBe(false);
});

test("when user is not recipe owner, and recipe is locked, cannot edit", () => {
const result = useRecipePermissions(
createRecipe({}, true),
createUser({ id: "other-user-id"}),
);
expect(result.canEditRecipe.value).toBe(false);
});

test("when user is recipe owner, and recipe is locked, can edit", () => {
const result = useRecipePermissions(createRecipe({}, true), createUser({}));
expect(result.canEditRecipe.value).toBe(true);
});
});
34 changes: 34 additions & 0 deletions frontend/composables/recipes/use-recipe-permissions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { computed } from "@nuxtjs/composition-api";
import { Recipe } from "~/lib/api/types/recipe";
import { UserOut } from "~/lib/api/types/user";

export function useRecipePermissions(recipe: Recipe, user: UserOut | null) {
const canEditRecipe = computed(() => {
// Check recipe owner
if (!user?.id) {
return false;
}
if (user.id === recipe.userId) {
return true;
}

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

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

return true;
});

return {
canEditRecipe,
}
}
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
Loading

0 comments on commit 9acf9ec

Please sign in to comment.