mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-24 15:49:42 +02:00
feat: Cross-Household Recipes (#4089)
This commit is contained in:
parent
7ef2e91ecf
commit
9acf9ec27c
16 changed files with 545 additions and 92 deletions
|
@ -21,31 +21,23 @@
|
||||||
|
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<div v-if="!open" class="custom-btn-group ma-1">
|
<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 />
|
<RecipeFavoriteBadge v-if="loggedIn" class="ml-1" color="info" button-style :recipe-id="recipe.id" show-always />
|
||||||
<RecipeTimelineBadge v-if="loggedIn" button-style :slug="recipe.slug" :recipe-name="recipe.name" />
|
<RecipeTimelineBadge v-if="loggedIn" button-style class="ml-1" :slug="recipe.slug" :recipe-name="recipe.name" />
|
||||||
<div v-if="loggedIn">
|
<div v-if="loggedIn">
|
||||||
<v-tooltip v-if="!locked" bottom color="info">
|
<v-tooltip v-if="canEdit" bottom color="info">
|
||||||
<template #activator="{ on, attrs }">
|
<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-icon> {{ $globals.icons.edit }} </v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<span>{{ $t("general.edit") }}</span>
|
<span>{{ $t("general.edit") }}</span>
|
||||||
</v-tooltip>
|
</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>
|
</div>
|
||||||
|
|
||||||
<RecipeTimerMenu
|
<RecipeTimerMenu
|
||||||
fab
|
fab
|
||||||
color="info"
|
color="info"
|
||||||
class="mr-1"
|
class="ml-1"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RecipeContextMenu
|
<RecipeContextMenu
|
||||||
|
@ -72,6 +64,7 @@
|
||||||
share: loggedIn,
|
share: loggedIn,
|
||||||
recipeActions: true,
|
recipeActions: true,
|
||||||
}"
|
}"
|
||||||
|
class="ml-1"
|
||||||
@print="$emit('print')"
|
@print="$emit('print')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -135,7 +128,7 @@ export default defineComponent({
|
||||||
required: true,
|
required: true,
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
locked: {
|
canEdit: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
|
|
@ -45,7 +45,7 @@
|
||||||
:recipe="recipe"
|
:recipe="recipe"
|
||||||
:slug="recipe.slug"
|
:slug="recipe.slug"
|
||||||
:recipe-scale="recipeScale"
|
:recipe-scale="recipeScale"
|
||||||
:locked="isOwnGroup && user.id !== recipe.userId && recipe.settings.locked"
|
:can-edit="canEditRecipe"
|
||||||
:name="recipe.name"
|
:name="recipe.name"
|
||||||
:logged-in="isOwnGroup"
|
:logged-in="isOwnGroup"
|
||||||
:open="isEditMode"
|
:open="isEditMode"
|
||||||
|
@ -64,6 +64,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
|
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
|
import { useRecipePermissions } from "~/composables/recipes";
|
||||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
||||||
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
||||||
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
|
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
|
||||||
|
@ -99,6 +100,7 @@ export default defineComponent({
|
||||||
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
||||||
const { user } = usePageUser();
|
const { user } = usePageUser();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
const { canEditRecipe } = useRecipePermissions(props.recipe, user);
|
||||||
|
|
||||||
function printRecipe() {
|
function printRecipe() {
|
||||||
window.print();
|
window.print();
|
||||||
|
@ -125,6 +127,7 @@ export default defineComponent({
|
||||||
setMode,
|
setMode,
|
||||||
toggleEditMode,
|
toggleEditMode,
|
||||||
recipeImage,
|
recipeImage,
|
||||||
|
canEditRecipe,
|
||||||
imageKey,
|
imageKey,
|
||||||
user,
|
user,
|
||||||
PageMode,
|
PageMode,
|
||||||
|
|
|
@ -4,3 +4,4 @@ export { useRecipes, recentRecipes, allRecipes, useLazyRecipes } from "./use-rec
|
||||||
export { parseIngredientText, useParsedIngredientText } from "./use-recipe-ingredients";
|
export { parseIngredientText, useParsedIngredientText } from "./use-recipe-ingredients";
|
||||||
export { useNutritionLabels } from "./use-recipe-nutrition";
|
export { useNutritionLabels } from "./use-recipe-nutrition";
|
||||||
export { useTools } from "./use-recipe-tools";
|
export { useTools } from "./use-recipe-tools";
|
||||||
|
export { useRecipePermissions } from "./use-recipe-permissions";
|
||||||
|
|
81
frontend/composables/recipes/use-recipe-permissions.test.ts
Normal file
81
frontend/composables/recipes/use-recipe-permissions.test.ts
Normal file
|
@ -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: "bender.rodriguez@example.com",
|
||||||
|
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
frontend/composables/recipes/use-recipe-permissions.ts
Normal file
34
frontend/composables/recipes/use-recipe-permissions.ts
Normal file
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -84,8 +84,10 @@ export const useLazyRecipes = function (publicGroupSlug: string | null = null) {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useRecipes = (
|
export const useRecipes = (
|
||||||
all = false, fetchRecipes = true,
|
all = false,
|
||||||
|
fetchRecipes = true,
|
||||||
loadFood = false,
|
loadFood = false,
|
||||||
|
queryFilter: string | null = null,
|
||||||
publicGroupSlug: string | null = null
|
publicGroupSlug: string | null = null
|
||||||
) => {
|
) => {
|
||||||
const api = publicGroupSlug ? usePublicExploreApi(publicGroupSlug).explore : useUserApi();
|
const api = publicGroupSlug ? usePublicExploreApi(publicGroupSlug).explore : useUserApi();
|
||||||
|
@ -108,7 +110,7 @@ export const useRecipes = (
|
||||||
})();
|
})();
|
||||||
|
|
||||||
async function refreshRecipes() {
|
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) {
|
if (data) {
|
||||||
recipes.value = data.items;
|
recipes.value = data.items;
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,8 +27,7 @@ export default defineComponent({
|
||||||
async function fetchHousehold() {
|
async function fetchHousehold() {
|
||||||
const { data } = await api.households.getCurrentUserHousehold();
|
const { data } = await api.households.getCurrentUserHousehold();
|
||||||
if (data) {
|
if (data) {
|
||||||
// TODO: once users are able to fetch other households' recipes, remove the household filter
|
queryFilter.value = `recipe.group_id="${data.groupId}"`;
|
||||||
queryFilter.value = `recipe.group_id="${data.groupId}" AND recipe.household_id="${data.id}"`;
|
|
||||||
groupName.value = data.group;
|
groupName.value = data.group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -178,10 +178,8 @@ export default defineComponent({
|
||||||
components: { RecipeDataTable, RecipeOrganizerSelector, GroupExportData, RecipeSettingsSwitches },
|
components: { RecipeDataTable, RecipeOrganizerSelector, GroupExportData, RecipeSettingsSwitches },
|
||||||
scrollToTop: true,
|
scrollToTop: true,
|
||||||
setup() {
|
setup() {
|
||||||
const { getAllRecipes, refreshRecipes } = useRecipes(true, true);
|
const { $auth, $globals, i18n } = useContext();
|
||||||
|
const { getAllRecipes, refreshRecipes } = useRecipes(true, true, false, `householdId=${$auth.user?.householdId || ""}`);
|
||||||
const { $globals, i18n } = useContext();
|
|
||||||
|
|
||||||
const selected = ref<Recipe[]>([]);
|
const selected = ref<Recipe[]>([]);
|
||||||
|
|
||||||
function resetAll() {
|
function resetAll() {
|
||||||
|
|
|
@ -32,6 +32,7 @@ from mealie.core.dependencies import (
|
||||||
from mealie.core.security import create_recipe_slug_token
|
from mealie.core.security import create_recipe_slug_token
|
||||||
from mealie.db.models.household.cookbook import CookBook
|
from mealie.db.models.household.cookbook import CookBook
|
||||||
from mealie.pkgs import cache
|
from mealie.pkgs import cache
|
||||||
|
from mealie.repos.all_repositories import get_repositories
|
||||||
from mealie.repos.repository_generic import RepositoryGeneric
|
from mealie.repos.repository_generic import RepositoryGeneric
|
||||||
from mealie.repos.repository_recipes import RepositoryRecipes
|
from mealie.repos.repository_recipes import RepositoryRecipes
|
||||||
from mealie.routes._base import BaseCrudController, controller
|
from mealie.routes._base import BaseCrudController, controller
|
||||||
|
@ -94,9 +95,13 @@ class JSONBytes(JSONResponse):
|
||||||
|
|
||||||
class BaseRecipeController(BaseCrudController):
|
class BaseRecipeController(BaseCrudController):
|
||||||
@cached_property
|
@cached_property
|
||||||
def repo(self) -> RepositoryRecipes:
|
def recipes(self) -> RepositoryRecipes:
|
||||||
return self.repos.recipes
|
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
|
@cached_property
|
||||||
def cookbooks_repo(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
|
def cookbooks_repo(self) -> RepositoryGeneric[ReadCookBook, CookBook]:
|
||||||
return self.repos.cookbooks
|
return self.repos.cookbooks
|
||||||
|
@ -107,7 +112,7 @@ class BaseRecipeController(BaseCrudController):
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def mixins(self):
|
def mixins(self):
|
||||||
return HttpRepo[CreateRecipe, Recipe, Recipe](self.repo, self.logger)
|
return HttpRepo[CreateRecipe, Recipe, Recipe](self.recipes, self.logger)
|
||||||
|
|
||||||
|
|
||||||
class FormatResponse(BaseModel):
|
class FormatResponse(BaseModel):
|
||||||
|
@ -331,8 +336,9 @@ class RecipeController(BaseRecipeController):
|
||||||
if cookbook_data is None:
|
if cookbook_data is None:
|
||||||
raise HTTPException(status_code=404, detail="cookbook not found")
|
raise HTTPException(status_code=404, detail="cookbook not found")
|
||||||
|
|
||||||
# we use the repo by user so we can sort favorites correctly
|
# We use "group_recipes" here so we can return all recipes regardless of household. The query filter can include
|
||||||
pagination_response = self.repos.recipes.by_user(self.user.id).page_all(
|
# 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,
|
pagination=q,
|
||||||
cookbook=cookbook_data,
|
cookbook=cookbook_data,
|
||||||
categories=categories,
|
categories=categories,
|
||||||
|
@ -362,7 +368,7 @@ class RecipeController(BaseRecipeController):
|
||||||
def get_one(self, slug: str = Path(..., description="A recipe's slug or id")):
|
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"""
|
"""Takes in a recipe's slug or id and returns all data for a recipe"""
|
||||||
try:
|
try:
|
||||||
recipe = self.service.get_one_by_slug_or_id(slug)
|
recipe = self.service.get_one(slug)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.handle_exceptions(e)
|
self.handle_exceptions(e)
|
||||||
return None
|
return None
|
||||||
|
@ -534,7 +540,7 @@ class RecipeController(BaseRecipeController):
|
||||||
data_service = RecipeDataService(recipe.id)
|
data_service = RecipeDataService(recipe.id)
|
||||||
data_service.write_image(image, extension)
|
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)
|
return UpdateImageResponse(image=new_version)
|
||||||
|
|
||||||
@router.post("/{slug}/assets", response_model=RecipeAsset, tags=["Recipe: Images and Assets"])
|
@router.post("/{slug}/assets", response_model=RecipeAsset, tags=["Recipe: Images and Assets"])
|
||||||
|
|
|
@ -4,6 +4,7 @@ from functools import cached_property
|
||||||
from fastapi import Depends, File, Form, HTTPException
|
from fastapi import Depends, File, Form, HTTPException
|
||||||
from pydantic import UUID4
|
from pydantic import UUID4
|
||||||
|
|
||||||
|
from mealie.repos.all_repositories import get_repositories
|
||||||
from mealie.routes._base import BaseCrudController, controller
|
from mealie.routes._base import BaseCrudController, controller
|
||||||
from mealie.routes._base.mixins import HttpRepo
|
from mealie.routes._base.mixins import HttpRepo
|
||||||
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
|
from mealie.routes._base.routers import MealieCrudRoute, UserAPIRouter
|
||||||
|
@ -31,8 +32,8 @@ class RecipeTimelineEventsController(BaseCrudController):
|
||||||
return self.repos.recipe_timeline_events
|
return self.repos.recipe_timeline_events
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def recipes_repo(self):
|
def group_recipes(self):
|
||||||
return self.repos.recipes
|
return get_repositories(self.session, group_id=self.group_id, household_id=None).recipes
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def mixins(self):
|
def mixins(self):
|
||||||
|
@ -57,7 +58,7 @@ class RecipeTimelineEventsController(BaseCrudController):
|
||||||
# if the user id is not specified, use the currently-authenticated user
|
# if the user id is not specified, use the currently-authenticated user
|
||||||
data.user_id = data.user_id or self.user.id
|
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:
|
if not recipe:
|
||||||
raise HTTPException(status_code=404, detail="recipe not found")
|
raise HTTPException(status_code=404, detail="recipe not found")
|
||||||
|
|
||||||
|
@ -87,7 +88,7 @@ class RecipeTimelineEventsController(BaseCrudController):
|
||||||
@events_router.put("/{item_id}", response_model=RecipeTimelineEventOut)
|
@events_router.put("/{item_id}", response_model=RecipeTimelineEventOut)
|
||||||
def update_one(self, item_id: UUID4, data: RecipeTimelineEventUpdate):
|
def update_one(self, item_id: UUID4, data: RecipeTimelineEventUpdate):
|
||||||
event = self.mixins.patch_one(data, item_id)
|
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:
|
if recipe:
|
||||||
self.publish_event(
|
self.publish_event(
|
||||||
event_type=EventTypes.recipe_updated,
|
event_type=EventTypes.recipe_updated,
|
||||||
|
@ -114,7 +115,7 @@ class RecipeTimelineEventsController(BaseCrudController):
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
recipe = self.recipes_repo.get_one(event.recipe_id, "id")
|
recipe = self.group_recipes.get_one(event.recipe_id, "id")
|
||||||
if recipe:
|
if recipe:
|
||||||
self.publish_event(
|
self.publish_event(
|
||||||
event_type=EventTypes.recipe_updated,
|
event_type=EventTypes.recipe_updated,
|
||||||
|
@ -144,7 +145,7 @@ class RecipeTimelineEventsController(BaseCrudController):
|
||||||
if event.image != TimelineEventImage.has_image.value:
|
if event.image != TimelineEventImage.has_image.value:
|
||||||
event.image = TimelineEventImage.has_image
|
event.image = TimelineEventImage.has_image
|
||||||
event = self.mixins.patch_one(event.cast(RecipeTimelineEventUpdate), event.id)
|
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:
|
if recipe:
|
||||||
self.publish_event(
|
self.publish_event(
|
||||||
event_type=EventTypes.recipe_updated,
|
event_type=EventTypes.recipe_updated,
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
|
from functools import cached_property
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from pydantic import UUID4
|
from pydantic import UUID4
|
||||||
|
|
||||||
|
from mealie.repos.all_repositories import get_repositories
|
||||||
from mealie.routes._base import BaseUserController, controller
|
from mealie.routes._base import BaseUserController, controller
|
||||||
from mealie.routes._base.routers import UserAPIRouter
|
from mealie.routes._base.routers import UserAPIRouter
|
||||||
from mealie.routes.users._helpers import assert_user_change_allowed
|
from mealie.routes.users._helpers import assert_user_change_allowed
|
||||||
|
@ -14,6 +16,10 @@ router = UserAPIRouter()
|
||||||
|
|
||||||
@controller(router)
|
@controller(router)
|
||||||
class UserRatingsController(BaseUserController):
|
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):
|
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."""
|
"""Fetches a recipe by slug or id, or raises a 404 error if not found."""
|
||||||
if isinstance(slug_or_id, str):
|
if isinstance(slug_or_id, str):
|
||||||
|
@ -22,11 +28,10 @@ class UserRatingsController(BaseUserController):
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
recipes_repo = self.repos.recipes
|
|
||||||
if isinstance(slug_or_id, UUID):
|
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:
|
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:
|
if not recipe:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
@ -16,6 +16,7 @@ from mealie.core.config import get_app_settings
|
||||||
from mealie.core.dependencies.dependencies import get_temporary_path
|
from mealie.core.dependencies.dependencies import get_temporary_path
|
||||||
from mealie.lang.providers import Translator
|
from mealie.lang.providers import Translator
|
||||||
from mealie.pkgs import cache
|
from mealie.pkgs import cache
|
||||||
|
from mealie.repos.all_repositories import get_repositories
|
||||||
from mealie.repos.repository_factory import AllRepositories
|
from mealie.repos.repository_factory import AllRepositories
|
||||||
from mealie.repos.repository_generic import RepositoryGeneric
|
from mealie.repos.repository_generic import RepositoryGeneric
|
||||||
from mealie.schema.household.household import HouseholdInDB
|
from mealie.schema.household.household import HouseholdInDB
|
||||||
|
@ -46,6 +47,9 @@ class RecipeServiceBase(BaseService):
|
||||||
if repos.household_id != user.household_id != household.id:
|
if repos.household_id != user.household_id != household.id:
|
||||||
raise Exception("household ids do not match")
|
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"""
|
||||||
|
|
||||||
self.translator = translator
|
self.translator = translator
|
||||||
self.t = translator.t
|
self.t = translator.t
|
||||||
|
|
||||||
|
@ -54,7 +58,7 @@ class RecipeServiceBase(BaseService):
|
||||||
|
|
||||||
class RecipeService(RecipeServiceBase):
|
class RecipeService(RecipeServiceBase):
|
||||||
def _get_recipe(self, data: str | UUID, key: str | None = None) -> Recipe:
|
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:
|
if recipe is None:
|
||||||
raise exceptions.NoEntryFound("Recipe not found.")
|
raise exceptions.NoEntryFound("Recipe not found.")
|
||||||
return recipe
|
return recipe
|
||||||
|
@ -62,7 +66,18 @@ class RecipeService(RecipeServiceBase):
|
||||||
def can_update(self, recipe: Recipe) -> bool:
|
def can_update(self, recipe: Recipe) -> bool:
|
||||||
if recipe.settings is None:
|
if recipe.settings is None:
|
||||||
raise exceptions.UnexpectedNone("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:
|
def can_lock_unlock(self, recipe: Recipe) -> bool:
|
||||||
return recipe.user_id == self.user.id
|
return recipe.user_id == self.user.id
|
||||||
|
@ -120,7 +135,7 @@ class RecipeService(RecipeServiceBase):
|
||||||
|
|
||||||
return Recipe(**additional_attrs)
|
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):
|
if isinstance(slug_or_id, str):
|
||||||
try:
|
try:
|
||||||
slug_or_id = UUID(slug_or_id)
|
slug_or_id = UUID(slug_or_id)
|
||||||
|
@ -393,9 +408,10 @@ class RecipeService(RecipeServiceBase):
|
||||||
return new_data
|
return new_data
|
||||||
|
|
||||||
def update_last_made(self, slug: str, timestamp: datetime) -> Recipe:
|
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)
|
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:
|
def delete_one(self, slug) -> Recipe:
|
||||||
recipe = self._get_recipe(slug)
|
recipe = self._get_recipe(slug)
|
||||||
|
|
|
@ -124,3 +124,20 @@ def test_admin_can_delete(
|
||||||
response = api_client.get(api_routes.comments_item_id(comment_id), headers=admin_user.token)
|
response = api_client.get(api_routes.comments_item_id(comment_id), headers=admin_user.token)
|
||||||
|
|
||||||
assert response.status_code == 404
|
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
|
||||||
|
|
|
@ -0,0 +1,186 @@
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.utils import api_routes
|
||||||
|
from tests.utils.factories import random_string
|
||||||
|
from tests.utils.fixture_schemas import TestUser
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("is_private_household", [True, False])
|
||||||
|
def test_duplicate_recipe_changes_household(
|
||||||
|
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool
|
||||||
|
):
|
||||||
|
household = unique_user.repos.households.get_one(h2_user.household_id)
|
||||||
|
assert household and household.preferences
|
||||||
|
household.preferences.private_household = is_private_household
|
||||||
|
unique_user.repos.household_preferences.update(household.id, household.preferences)
|
||||||
|
|
||||||
|
source_recipe_name = random_string()
|
||||||
|
duplicate_recipe_name = random_string()
|
||||||
|
|
||||||
|
response = api_client.post(api_routes.recipes, json={"name": source_recipe_name}, headers=unique_user.token)
|
||||||
|
assert response.status_code == 201
|
||||||
|
recipe = unique_user.repos.recipes.get_one(response.json())
|
||||||
|
assert recipe
|
||||||
|
assert recipe.name == source_recipe_name
|
||||||
|
assert str(recipe.household_id) == unique_user.household_id
|
||||||
|
|
||||||
|
response = api_client.post(
|
||||||
|
api_routes.recipes_slug_duplicate(recipe.slug), json={"name": duplicate_recipe_name}, headers=h2_user.token
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
duplicate_recipe = h2_user.repos.recipes.get_one(response.json()["slug"])
|
||||||
|
assert duplicate_recipe
|
||||||
|
assert duplicate_recipe.name == duplicate_recipe_name
|
||||||
|
assert str(duplicate_recipe.household_id) == h2_user.household_id != unique_user.household_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("is_private_household", [True, False])
|
||||||
|
def test_get_all_recipes_includes_all_households(
|
||||||
|
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool
|
||||||
|
):
|
||||||
|
household = unique_user.repos.households.get_one(h2_user.household_id)
|
||||||
|
assert household and household.preferences
|
||||||
|
household.preferences.private_household = is_private_household
|
||||||
|
unique_user.repos.household_preferences.update(household.id, household.preferences)
|
||||||
|
|
||||||
|
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token)
|
||||||
|
assert response.status_code == 201
|
||||||
|
recipe = unique_user.repos.recipes.get_one(response.json())
|
||||||
|
assert recipe and recipe.id
|
||||||
|
recipe_id = recipe.id
|
||||||
|
|
||||||
|
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token)
|
||||||
|
assert response.status_code == 201
|
||||||
|
h2_recipe = h2_user.repos.recipes.get_one(response.json())
|
||||||
|
assert h2_recipe and h2_recipe.id
|
||||||
|
h2_recipe_id = h2_recipe.id
|
||||||
|
|
||||||
|
response = api_client.get(api_routes.recipes, params={"page": 1, "perPage": -1}, headers=unique_user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
response_ids = {recipe["id"] for recipe in response.json()["items"]}
|
||||||
|
assert str(recipe_id) in response_ids
|
||||||
|
assert str(h2_recipe_id) in response_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("is_private_household", [True, False])
|
||||||
|
def test_get_one_recipe_from_another_household(
|
||||||
|
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool
|
||||||
|
):
|
||||||
|
household = unique_user.repos.households.get_one(h2_user.household_id)
|
||||||
|
assert household and household.preferences
|
||||||
|
household.preferences.private_household = is_private_household
|
||||||
|
unique_user.repos.household_preferences.update(household.id, household.preferences)
|
||||||
|
|
||||||
|
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token)
|
||||||
|
assert response.status_code == 201
|
||||||
|
h2_recipe = h2_user.repos.recipes.get_one(response.json())
|
||||||
|
assert h2_recipe and h2_recipe.id
|
||||||
|
h2_recipe_id = h2_recipe.id
|
||||||
|
|
||||||
|
response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["id"] == str(h2_recipe_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("is_private_household", [True, False])
|
||||||
|
@pytest.mark.parametrize("use_patch", [True, False])
|
||||||
|
def test_prevent_updates_to_recipes_from_other_households(
|
||||||
|
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool, use_patch: bool
|
||||||
|
):
|
||||||
|
household = unique_user.repos.households.get_one(h2_user.household_id)
|
||||||
|
assert household and household.preferences
|
||||||
|
household.preferences.private_household = is_private_household
|
||||||
|
unique_user.repos.household_preferences.update(household.id, household.preferences)
|
||||||
|
|
||||||
|
original_name = random_string()
|
||||||
|
response = api_client.post(api_routes.recipes, json={"name": original_name}, headers=h2_user.token)
|
||||||
|
assert response.status_code == 201
|
||||||
|
h2_recipe = h2_user.repos.recipes.get_one(response.json())
|
||||||
|
assert h2_recipe and h2_recipe.id
|
||||||
|
h2_recipe_id = h2_recipe.id
|
||||||
|
|
||||||
|
response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
recipe = response.json()
|
||||||
|
assert recipe["id"] == str(h2_recipe_id)
|
||||||
|
|
||||||
|
updated_name = random_string()
|
||||||
|
recipe["name"] = updated_name
|
||||||
|
client_func = api_client.patch if use_patch else api_client.put
|
||||||
|
response = client_func(api_routes.recipes_slug(recipe["slug"]), json=recipe, headers=unique_user.token)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
# confirm the recipe is unchanged
|
||||||
|
response = api_client.get(api_routes.recipes_slug(recipe["slug"]), headers=unique_user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
updated_recipe = response.json()
|
||||||
|
assert updated_recipe["name"] == original_name != updated_name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("is_private_household", [True, False])
|
||||||
|
def test_prevent_deletes_to_recipes_from_other_households(
|
||||||
|
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool
|
||||||
|
):
|
||||||
|
household = unique_user.repos.households.get_one(h2_user.household_id)
|
||||||
|
assert household and household.preferences
|
||||||
|
household.preferences.private_household = is_private_household
|
||||||
|
unique_user.repos.household_preferences.update(household.id, household.preferences)
|
||||||
|
|
||||||
|
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token)
|
||||||
|
assert response.status_code == 201
|
||||||
|
h2_recipe = h2_user.repos.recipes.get_one(response.json())
|
||||||
|
assert h2_recipe and h2_recipe.id
|
||||||
|
h2_recipe_id = str(h2_recipe.id)
|
||||||
|
|
||||||
|
response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
recipe_json = response.json()
|
||||||
|
assert recipe_json["id"] == h2_recipe_id
|
||||||
|
|
||||||
|
response = api_client.delete(api_routes.recipes_slug(recipe_json["slug"]), headers=unique_user.token)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
# confirm the recipe still exists
|
||||||
|
response = api_client.get(api_routes.recipes_slug(h2_recipe_id), headers=unique_user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["id"] == h2_recipe_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("is_private_household", [True, False])
|
||||||
|
def test_user_can_update_last_made_on_other_household(
|
||||||
|
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, is_private_household: bool
|
||||||
|
):
|
||||||
|
household = unique_user.repos.households.get_one(h2_user.household_id)
|
||||||
|
assert household and household.preferences
|
||||||
|
household.preferences.private_household = is_private_household
|
||||||
|
unique_user.repos.household_preferences.update(household.id, household.preferences)
|
||||||
|
|
||||||
|
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=h2_user.token)
|
||||||
|
assert response.status_code == 201
|
||||||
|
h2_recipe = h2_user.repos.recipes.get_one(response.json())
|
||||||
|
assert h2_recipe and h2_recipe.id
|
||||||
|
h2_recipe_id = h2_recipe.id
|
||||||
|
h2_recipe_slug = h2_recipe.slug
|
||||||
|
|
||||||
|
response = api_client.get(api_routes.recipes_slug(h2_recipe_slug), headers=unique_user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
recipe = response.json()
|
||||||
|
assert recipe["id"] == str(h2_recipe_id)
|
||||||
|
old_last_made = recipe["lastMade"]
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
response = api_client.patch(
|
||||||
|
api_routes.recipes_slug_last_made(h2_recipe_slug), json={"timestamp": now}, headers=unique_user.token
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# confirm the last made date was updated
|
||||||
|
response = api_client.get(api_routes.recipes_slug(h2_recipe_slug), headers=unique_user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
recipe = response.json()
|
||||||
|
assert recipe["id"] == str(h2_recipe_id)
|
||||||
|
new_last_made = recipe["lastMade"]
|
||||||
|
assert new_last_made == now != old_last_made
|
|
@ -368,3 +368,47 @@ def test_recipe_rating_is_readonly(
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["rating"] == rating.rating
|
assert data["rating"] == rating.rating
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_can_rate_recipes_in_other_households(api_client: TestClient, unique_user: TestUser, h2_user: TestUser):
|
||||||
|
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token)
|
||||||
|
assert response.status_code == 201
|
||||||
|
recipe = unique_user.repos.recipes.get_one(response.json())
|
||||||
|
assert recipe and recipe.id
|
||||||
|
|
||||||
|
rating = UserRatingUpdate(rating=random.uniform(1, 5), is_favorite=True)
|
||||||
|
response = api_client.post(
|
||||||
|
api_routes.users_id_ratings_slug(h2_user.user_id, recipe.slug),
|
||||||
|
json=rating.model_dump(),
|
||||||
|
headers=h2_user.token,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
response = api_client.get(api_routes.users_self_ratings_recipe_id(recipe.id), headers=h2_user.token)
|
||||||
|
data = response.json()
|
||||||
|
assert data["recipeId"] == str(recipe.id)
|
||||||
|
assert data["rating"] == rating.rating
|
||||||
|
assert data["isFavorite"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_average_recipe_rating_includes_all_households(
|
||||||
|
api_client: TestClient, unique_user: TestUser, h2_user: TestUser
|
||||||
|
):
|
||||||
|
response = api_client.post(api_routes.recipes, json={"name": random_string()}, headers=unique_user.token)
|
||||||
|
assert response.status_code == 201
|
||||||
|
recipe = unique_user.repos.recipes.get_one(response.json())
|
||||||
|
assert recipe
|
||||||
|
|
||||||
|
user_ratings = (UserRatingUpdate(rating=5), UserRatingUpdate(rating=2))
|
||||||
|
for i, user in enumerate([unique_user, h2_user]):
|
||||||
|
response = api_client.post(
|
||||||
|
api_routes.users_id_ratings_slug(user.user_id, recipe.slug),
|
||||||
|
json=user_ratings[i].model_dump(),
|
||||||
|
headers=user.token,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
response = api_client.get(api_routes.recipes_slug(recipe.slug), headers=unique_user.token)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["rating"] == 3.5
|
||||||
|
|
|
@ -35,11 +35,19 @@ def recipes(api_client: TestClient, unique_user: TestUser):
|
||||||
response = api_client.delete(f"{api_routes.recipes}/{slug}", headers=unique_user.token)
|
response = api_client.delete(f"{api_routes.recipes}/{slug}", headers=unique_user.token)
|
||||||
|
|
||||||
|
|
||||||
def test_create_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
|
@pytest.mark.parametrize("use_other_household_user", [True, False])
|
||||||
|
def test_create_timeline_event(
|
||||||
|
api_client: TestClient,
|
||||||
|
unique_user: TestUser,
|
||||||
|
recipes: list[Recipe],
|
||||||
|
h2_user: TestUser,
|
||||||
|
use_other_household_user: bool,
|
||||||
|
):
|
||||||
|
user = h2_user if use_other_household_user else unique_user
|
||||||
recipe = recipes[0]
|
recipe = recipes[0]
|
||||||
new_event = {
|
new_event = {
|
||||||
"recipe_id": str(recipe.id),
|
"recipe_id": str(recipe.id),
|
||||||
"user_id": str(unique_user.user_id),
|
"user_id": str(user.user_id),
|
||||||
"subject": random_string(),
|
"subject": random_string(),
|
||||||
"event_type": "info",
|
"event_type": "info",
|
||||||
"message": random_string(),
|
"message": random_string(),
|
||||||
|
@ -48,41 +56,53 @@ def test_create_timeline_event(api_client: TestClient, unique_user: TestUser, re
|
||||||
event_response = api_client.post(
|
event_response = api_client.post(
|
||||||
api_routes.recipes_timeline_events,
|
api_routes.recipes_timeline_events,
|
||||||
json=new_event,
|
json=new_event,
|
||||||
headers=unique_user.token,
|
headers=user.token,
|
||||||
)
|
)
|
||||||
assert event_response.status_code == 201
|
assert event_response.status_code == 201
|
||||||
|
|
||||||
event = RecipeTimelineEventOut.model_validate(event_response.json())
|
event = RecipeTimelineEventOut.model_validate(event_response.json())
|
||||||
assert event.recipe_id == recipe.id
|
assert event.recipe_id == recipe.id
|
||||||
assert str(event.user_id) == str(unique_user.user_id)
|
assert str(event.user_id) == str(user.user_id)
|
||||||
|
|
||||||
|
|
||||||
def test_get_all_timeline_events(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
|
@pytest.mark.parametrize("use_other_household_user", [True, False])
|
||||||
|
def test_get_all_timeline_events(
|
||||||
|
api_client: TestClient,
|
||||||
|
unique_user: TestUser,
|
||||||
|
recipes: list[Recipe],
|
||||||
|
h2_user: TestUser,
|
||||||
|
use_other_household_user: bool,
|
||||||
|
):
|
||||||
|
user = h2_user if use_other_household_user else unique_user
|
||||||
# create some events
|
# create some events
|
||||||
recipe = recipes[0]
|
recipe = recipes[0]
|
||||||
events_data = [
|
events_data: list[dict] = []
|
||||||
{
|
for user in [unique_user, h2_user]:
|
||||||
"recipe_id": str(recipe.id),
|
events_data.extend(
|
||||||
"user_id": str(unique_user.user_id),
|
[
|
||||||
"subject": random_string(),
|
{
|
||||||
"event_type": "info",
|
"recipe_id": str(recipe.id),
|
||||||
"message": random_string(),
|
"user_id": str(user.user_id),
|
||||||
}
|
"subject": random_string(),
|
||||||
for _ in range(10)
|
"event_type": "info",
|
||||||
]
|
"message": random_string(),
|
||||||
|
}
|
||||||
|
for _ in range(10)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
events: list[RecipeTimelineEventOut] = []
|
events: list[RecipeTimelineEventOut] = []
|
||||||
for event_data in events_data:
|
for event_data in events_data:
|
||||||
params: dict = {"queryFilter": f"recipe_id={event_data['recipe_id']}"}
|
params: dict = {"queryFilter": f"recipe_id={event_data['recipe_id']}"}
|
||||||
event_response = api_client.post(
|
event_response = api_client.post(
|
||||||
api_routes.recipes_timeline_events, params=params, json=event_data, headers=unique_user.token
|
api_routes.recipes_timeline_events, params=params, json=event_data, headers=user.token
|
||||||
)
|
)
|
||||||
events.append(RecipeTimelineEventOut.model_validate(event_response.json()))
|
events.append(RecipeTimelineEventOut.model_validate(event_response.json()))
|
||||||
|
|
||||||
# check that we see them all
|
# check that we see them all
|
||||||
params = {"page": 1, "perPage": -1}
|
params = {"page": 1, "perPage": -1}
|
||||||
|
|
||||||
events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token)
|
events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=user.token)
|
||||||
events_pagination = RecipeTimelineEventPagination.model_validate(events_response.json())
|
events_pagination = RecipeTimelineEventPagination.model_validate(events_response.json())
|
||||||
|
|
||||||
event_ids = [event.id for event in events]
|
event_ids = [event.id for event in events]
|
||||||
|
@ -93,12 +113,20 @@ def test_get_all_timeline_events(api_client: TestClient, unique_user: TestUser,
|
||||||
assert event_id in paginated_event_ids
|
assert event_id in paginated_event_ids
|
||||||
|
|
||||||
|
|
||||||
def test_get_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
|
@pytest.mark.parametrize("use_other_household_user", [True, False])
|
||||||
|
def test_get_timeline_event(
|
||||||
|
api_client: TestClient,
|
||||||
|
unique_user: TestUser,
|
||||||
|
recipes: list[Recipe],
|
||||||
|
h2_user: TestUser,
|
||||||
|
use_other_household_user: bool,
|
||||||
|
):
|
||||||
|
user = h2_user if use_other_household_user else unique_user
|
||||||
# create an event
|
# create an event
|
||||||
recipe = recipes[0]
|
recipe = recipes[0]
|
||||||
new_event_data = {
|
new_event_data = {
|
||||||
"recipe_id": str(recipe.id),
|
"recipe_id": str(recipe.id),
|
||||||
"user_id": str(unique_user.user_id),
|
"user_id": str(user.user_id),
|
||||||
"subject": random_string(),
|
"subject": random_string(),
|
||||||
"event_type": "info",
|
"event_type": "info",
|
||||||
"message": random_string(),
|
"message": random_string(),
|
||||||
|
@ -107,19 +135,27 @@ def test_get_timeline_event(api_client: TestClient, unique_user: TestUser, recip
|
||||||
event_response = api_client.post(
|
event_response = api_client.post(
|
||||||
api_routes.recipes_timeline_events,
|
api_routes.recipes_timeline_events,
|
||||||
json=new_event_data,
|
json=new_event_data,
|
||||||
headers=unique_user.token,
|
headers=user.token,
|
||||||
)
|
)
|
||||||
new_event = RecipeTimelineEventOut.model_validate(event_response.json())
|
new_event = RecipeTimelineEventOut.model_validate(event_response.json())
|
||||||
|
|
||||||
# fetch the new event
|
# fetch the new event
|
||||||
event_response = api_client.get(api_routes.recipes_timeline_events_item_id(new_event.id), headers=unique_user.token)
|
event_response = api_client.get(api_routes.recipes_timeline_events_item_id(new_event.id), headers=user.token)
|
||||||
assert event_response.status_code == 200
|
assert event_response.status_code == 200
|
||||||
|
|
||||||
event = RecipeTimelineEventOut.model_validate(event_response.json())
|
event = RecipeTimelineEventOut.model_validate(event_response.json())
|
||||||
assert event == new_event
|
assert event == new_event
|
||||||
|
|
||||||
|
|
||||||
def test_update_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
|
@pytest.mark.parametrize("use_other_household_user", [True, False])
|
||||||
|
def test_update_timeline_event(
|
||||||
|
api_client: TestClient,
|
||||||
|
unique_user: TestUser,
|
||||||
|
recipes: list[Recipe],
|
||||||
|
h2_user: TestUser,
|
||||||
|
use_other_household_user: bool,
|
||||||
|
):
|
||||||
|
user = h2_user if use_other_household_user else unique_user
|
||||||
old_subject = random_string()
|
old_subject = random_string()
|
||||||
new_subject = random_string()
|
new_subject = random_string()
|
||||||
|
|
||||||
|
@ -127,12 +163,12 @@ def test_update_timeline_event(api_client: TestClient, unique_user: TestUser, re
|
||||||
recipe = recipes[0]
|
recipe = recipes[0]
|
||||||
new_event_data = {
|
new_event_data = {
|
||||||
"recipe_id": str(recipe.id),
|
"recipe_id": str(recipe.id),
|
||||||
"user_id": str(unique_user.user_id),
|
"user_id": str(user.user_id),
|
||||||
"subject": old_subject,
|
"subject": old_subject,
|
||||||
"event_type": "info",
|
"event_type": "info",
|
||||||
}
|
}
|
||||||
|
|
||||||
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=unique_user.token)
|
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=user.token)
|
||||||
new_event = RecipeTimelineEventOut.model_validate(event_response.json())
|
new_event = RecipeTimelineEventOut.model_validate(event_response.json())
|
||||||
assert new_event.subject == old_subject
|
assert new_event.subject == old_subject
|
||||||
|
|
||||||
|
@ -142,7 +178,7 @@ def test_update_timeline_event(api_client: TestClient, unique_user: TestUser, re
|
||||||
event_response = api_client.put(
|
event_response = api_client.put(
|
||||||
api_routes.recipes_timeline_events_item_id(new_event.id),
|
api_routes.recipes_timeline_events_item_id(new_event.id),
|
||||||
json=updated_event_data,
|
json=updated_event_data,
|
||||||
headers=unique_user.token,
|
headers=user.token,
|
||||||
)
|
)
|
||||||
assert event_response.status_code == 200
|
assert event_response.status_code == 200
|
||||||
|
|
||||||
|
@ -152,42 +188,54 @@ def test_update_timeline_event(api_client: TestClient, unique_user: TestUser, re
|
||||||
assert updated_event.timestamp == new_event.timestamp
|
assert updated_event.timestamp == new_event.timestamp
|
||||||
|
|
||||||
|
|
||||||
def test_delete_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
|
@pytest.mark.parametrize("use_other_household_user", [True, False])
|
||||||
|
def test_delete_timeline_event(
|
||||||
|
api_client: TestClient,
|
||||||
|
unique_user: TestUser,
|
||||||
|
recipes: list[Recipe],
|
||||||
|
h2_user: TestUser,
|
||||||
|
use_other_household_user: bool,
|
||||||
|
):
|
||||||
|
user = h2_user if use_other_household_user else unique_user
|
||||||
# create an event
|
# create an event
|
||||||
recipe = recipes[0]
|
recipe = recipes[0]
|
||||||
new_event_data = {
|
new_event_data = {
|
||||||
"recipe_id": str(recipe.id),
|
"recipe_id": str(recipe.id),
|
||||||
"user_id": str(unique_user.user_id),
|
"user_id": str(user.user_id),
|
||||||
"subject": random_string(),
|
"subject": random_string(),
|
||||||
"event_type": "info",
|
"event_type": "info",
|
||||||
"message": random_string(),
|
"message": random_string(),
|
||||||
}
|
}
|
||||||
|
|
||||||
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=unique_user.token)
|
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=user.token)
|
||||||
new_event = RecipeTimelineEventOut.model_validate(event_response.json())
|
new_event = RecipeTimelineEventOut.model_validate(event_response.json())
|
||||||
|
|
||||||
# delete the event
|
# delete the event
|
||||||
event_response = api_client.delete(
|
event_response = api_client.delete(api_routes.recipes_timeline_events_item_id(new_event.id), headers=user.token)
|
||||||
api_routes.recipes_timeline_events_item_id(new_event.id), headers=unique_user.token
|
|
||||||
)
|
|
||||||
assert event_response.status_code == 200
|
assert event_response.status_code == 200
|
||||||
|
|
||||||
deleted_event = RecipeTimelineEventOut.model_validate(event_response.json())
|
deleted_event = RecipeTimelineEventOut.model_validate(event_response.json())
|
||||||
assert deleted_event.id == new_event.id
|
assert deleted_event.id == new_event.id
|
||||||
|
|
||||||
# try to get the event
|
# try to get the event
|
||||||
event_response = api_client.get(
|
event_response = api_client.get(api_routes.recipes_timeline_events_item_id(deleted_event.id), headers=user.token)
|
||||||
api_routes.recipes_timeline_events_item_id(deleted_event.id), headers=unique_user.token
|
|
||||||
)
|
|
||||||
assert event_response.status_code == 404
|
assert event_response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
|
@pytest.mark.parametrize("use_other_household_user", [True, False])
|
||||||
|
def test_timeline_event_message_alias(
|
||||||
|
api_client: TestClient,
|
||||||
|
unique_user: TestUser,
|
||||||
|
recipes: list[Recipe],
|
||||||
|
h2_user: TestUser,
|
||||||
|
use_other_household_user: bool,
|
||||||
|
):
|
||||||
|
user = h2_user if use_other_household_user else unique_user
|
||||||
# create an event using aliases
|
# create an event using aliases
|
||||||
recipe = recipes[0]
|
recipe = recipes[0]
|
||||||
new_event_data = {
|
new_event_data = {
|
||||||
"recipeId": str(recipe.id),
|
"recipeId": str(recipe.id),
|
||||||
"userId": str(unique_user.user_id),
|
"userId": str(user.user_id),
|
||||||
"subject": random_string(),
|
"subject": random_string(),
|
||||||
"eventType": "info",
|
"eventType": "info",
|
||||||
"eventMessage": random_string(), # eventMessage is the correct alias for the message
|
"eventMessage": random_string(), # eventMessage is the correct alias for the message
|
||||||
|
@ -196,7 +244,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
|
||||||
event_response = api_client.post(
|
event_response = api_client.post(
|
||||||
api_routes.recipes_timeline_events,
|
api_routes.recipes_timeline_events,
|
||||||
json=new_event_data,
|
json=new_event_data,
|
||||||
headers=unique_user.token,
|
headers=user.token,
|
||||||
)
|
)
|
||||||
new_event = RecipeTimelineEventOut.model_validate(event_response.json())
|
new_event = RecipeTimelineEventOut.model_validate(event_response.json())
|
||||||
assert str(new_event.user_id) == new_event_data["userId"]
|
assert str(new_event.user_id) == new_event_data["userId"]
|
||||||
|
@ -204,7 +252,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
|
||||||
assert new_event.message == new_event_data["eventMessage"]
|
assert new_event.message == new_event_data["eventMessage"]
|
||||||
|
|
||||||
# fetch the new event
|
# fetch the new event
|
||||||
event_response = api_client.get(api_routes.recipes_timeline_events_item_id(new_event.id), headers=unique_user.token)
|
event_response = api_client.get(api_routes.recipes_timeline_events_item_id(new_event.id), headers=user.token)
|
||||||
assert event_response.status_code == 200
|
assert event_response.status_code == 200
|
||||||
|
|
||||||
event = RecipeTimelineEventOut.model_validate(event_response.json())
|
event = RecipeTimelineEventOut.model_validate(event_response.json())
|
||||||
|
@ -218,7 +266,7 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
|
||||||
event_response = api_client.put(
|
event_response = api_client.put(
|
||||||
api_routes.recipes_timeline_events_item_id(new_event.id),
|
api_routes.recipes_timeline_events_item_id(new_event.id),
|
||||||
json=updated_event_data,
|
json=updated_event_data,
|
||||||
headers=unique_user.token,
|
headers=user.token,
|
||||||
)
|
)
|
||||||
assert event_response.status_code == 200
|
assert event_response.status_code == 200
|
||||||
|
|
||||||
|
@ -227,20 +275,27 @@ def test_timeline_event_message_alias(api_client: TestClient, unique_user: TestU
|
||||||
assert updated_event.message == new_message
|
assert updated_event.message == new_message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("use_other_household_user", [True, False])
|
||||||
def test_timeline_event_update_image(
|
def test_timeline_event_update_image(
|
||||||
api_client: TestClient, unique_user: TestUser, recipes: list[Recipe], test_image_jpg: str
|
api_client: TestClient,
|
||||||
|
unique_user: TestUser,
|
||||||
|
recipes: list[Recipe],
|
||||||
|
test_image_jpg: str,
|
||||||
|
h2_user: TestUser,
|
||||||
|
use_other_household_user: bool,
|
||||||
):
|
):
|
||||||
|
user = h2_user if use_other_household_user else unique_user
|
||||||
# create an event
|
# create an event
|
||||||
recipe = recipes[0]
|
recipe = recipes[0]
|
||||||
new_event_data = {
|
new_event_data = {
|
||||||
"recipe_id": str(recipe.id),
|
"recipe_id": str(recipe.id),
|
||||||
"user_id": str(unique_user.user_id),
|
"user_id": str(user.user_id),
|
||||||
"subject": random_string(),
|
"subject": random_string(),
|
||||||
"message": random_string(),
|
"message": random_string(),
|
||||||
"event_type": "info",
|
"event_type": "info",
|
||||||
}
|
}
|
||||||
|
|
||||||
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=unique_user.token)
|
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=user.token)
|
||||||
new_event = RecipeTimelineEventOut.model_validate(event_response.json())
|
new_event = RecipeTimelineEventOut.model_validate(event_response.json())
|
||||||
assert new_event.image == TimelineEventImage.does_not_have_image.value
|
assert new_event.image == TimelineEventImage.does_not_have_image.value
|
||||||
|
|
||||||
|
@ -249,7 +304,7 @@ def test_timeline_event_update_image(
|
||||||
api_routes.recipes_timeline_events_item_id_image(new_event.id),
|
api_routes.recipes_timeline_events_item_id_image(new_event.id),
|
||||||
files={"image": ("test_image_jpg.jpg", f, "image/jpeg")},
|
files={"image": ("test_image_jpg.jpg", f, "image/jpeg")},
|
||||||
data={"extension": "jpg"},
|
data={"extension": "jpg"},
|
||||||
headers=unique_user.token,
|
headers=user.token,
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
|
@ -258,7 +313,7 @@ def test_timeline_event_update_image(
|
||||||
|
|
||||||
event_response = api_client.get(
|
event_response = api_client.get(
|
||||||
api_routes.recipes_timeline_events_item_id(new_event.id),
|
api_routes.recipes_timeline_events_item_id(new_event.id),
|
||||||
headers=unique_user.token,
|
headers=user.token,
|
||||||
)
|
)
|
||||||
assert event_response.status_code == 200
|
assert event_response.status_code == 200
|
||||||
|
|
||||||
|
@ -269,23 +324,35 @@ def test_timeline_event_update_image(
|
||||||
assert updated_event.image == TimelineEventImage.has_image.value
|
assert updated_event.image == TimelineEventImage.has_image.value
|
||||||
|
|
||||||
|
|
||||||
def test_create_recipe_with_timeline_event(api_client: TestClient, unique_user: TestUser, recipes: list[Recipe]):
|
@pytest.mark.parametrize("use_other_household_user", [True, False])
|
||||||
|
def test_create_recipe_with_timeline_event(
|
||||||
|
api_client: TestClient,
|
||||||
|
unique_user: TestUser,
|
||||||
|
recipes: list[Recipe],
|
||||||
|
h2_user: TestUser,
|
||||||
|
use_other_household_user: bool,
|
||||||
|
):
|
||||||
|
user = h2_user if use_other_household_user else unique_user
|
||||||
# make sure when the recipes fixture was created that all recipes have at least one event
|
# make sure when the recipes fixture was created that all recipes have at least one event
|
||||||
for recipe in recipes:
|
for recipe in recipes:
|
||||||
params = {"queryFilter": f"recipe_id={recipe.id}"}
|
params = {"queryFilter": f"recipe_id={recipe.id}"}
|
||||||
events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=unique_user.token)
|
events_response = api_client.get(api_routes.recipes_timeline_events, params=params, headers=user.token)
|
||||||
events_pagination = RecipeTimelineEventPagination.model_validate(events_response.json())
|
events_pagination = RecipeTimelineEventPagination.model_validate(events_response.json())
|
||||||
assert events_pagination.items
|
assert events_pagination.items
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_recipe_id(api_client: TestClient, unique_user: TestUser):
|
@pytest.mark.parametrize("use_other_household_user", [True, False])
|
||||||
|
def test_invalid_recipe_id(
|
||||||
|
api_client: TestClient, unique_user: TestUser, h2_user: TestUser, use_other_household_user: bool
|
||||||
|
):
|
||||||
|
user = h2_user if use_other_household_user else unique_user
|
||||||
new_event_data = {
|
new_event_data = {
|
||||||
"recipe_id": str(uuid4()),
|
"recipe_id": str(uuid4()),
|
||||||
"user_id": str(unique_user.user_id),
|
"user_id": str(user.user_id),
|
||||||
"subject": random_string(),
|
"subject": random_string(),
|
||||||
"event_type": "info",
|
"event_type": "info",
|
||||||
"message": random_string(),
|
"message": random_string(),
|
||||||
}
|
}
|
||||||
|
|
||||||
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=unique_user.token)
|
event_response = api_client.post(api_routes.recipes_timeline_events, json=new_event_data, headers=user.token)
|
||||||
assert event_response.status_code == 404
|
assert event_response.status_code == 404
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue