From 70970ae2e5eaf7115b7bd2d602f4fdd9a42f383c Mon Sep 17 00:00:00 2001 From: Garrett Potter Date: Thu, 2 Jan 2025 20:01:14 +0000 Subject: [PATCH 1/5] Add bookmark flag to recipes --- .../components/Domain/Recipe/RecipeCard.vue | 19 ++++- .../components/Domain/Recipe/RecipeRating.vue | 2 +- .../Domain/Recipe/RecipeToCookBadge.vue | 75 +++++++++++++++++++ .../composables/use-users/user-ratings.ts | 4 +- frontend/lang/messages/en-US.json | 7 +- frontend/lib/api/types/user.ts | 4 + frontend/lib/api/user/users.ts | 23 +++++- frontend/lib/icons/icons.ts | 4 + ...3c341_add_is_bookmarked_to_usertorecipe.py | 28 +++++++ mealie/db/models/users/user_to_recipe.py | 1 + mealie/repos/repository_users.py | 5 +- mealie/routes/users/ratings.py | 20 ++++- mealie/schema/user/user.py | 6 ++ 13 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 frontend/components/Domain/Recipe/RecipeToCookBadge.vue create mode 100644 mealie/alembic/versions/2025-01-02-19.45.09_67abc573c341_add_is_bookmarked_to_usertorecipe.py diff --git a/frontend/components/Domain/Recipe/RecipeCard.vue b/frontend/components/Domain/Recipe/RecipeCard.vue index b6c627c60d3..07dd45b45b1 100644 --- a/frontend/components/Domain/Recipe/RecipeCard.vue +++ b/frontend/components/Domain/Recipe/RecipeCard.vue @@ -9,6 +9,8 @@ :min-height="imageHeight + 75" @click="$emit('click')" > + +
+
+ +
@@ -27,6 +41,8 @@
+
+
{{ name }} @@ -75,10 +91,11 @@ import RecipeChips from "./RecipeChips.vue"; import RecipeContextMenu from "./RecipeContextMenu.vue"; import RecipeCardImage from "./RecipeCardImage.vue"; import RecipeRating from "./RecipeRating.vue"; +import RecipeToCookBadge from "./RecipeToCookBadge.vue"; import { useLoggedInState } from "~/composables/use-logged-in-state"; export default defineComponent({ - components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage }, + components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage, RecipeToCookBadge }, props: { name: { type: String, diff --git a/frontend/components/Domain/Recipe/RecipeRating.vue b/frontend/components/Domain/Recipe/RecipeRating.vue index e8cd6c04169..b0ab1bd246b 100644 --- a/frontend/components/Domain/Recipe/RecipeRating.vue +++ b/frontend/components/Domain/Recipe/RecipeRating.vue @@ -88,7 +88,7 @@ export default defineComponent({ } if (!props.emitOnly) { - setRating(props.slug, val || 0, null); + setRating(props.slug, val || 0, null, null); } context.emit("input", val); } diff --git a/frontend/components/Domain/Recipe/RecipeToCookBadge.vue b/frontend/components/Domain/Recipe/RecipeToCookBadge.vue new file mode 100644 index 00000000000..4bbcc9dfe7d --- /dev/null +++ b/frontend/components/Domain/Recipe/RecipeToCookBadge.vue @@ -0,0 +1,75 @@ + + + diff --git a/frontend/composables/use-users/user-ratings.ts b/frontend/composables/use-users/user-ratings.ts index 0f82cd71893..b39b437e5b9 100644 --- a/frontend/composables/use-users/user-ratings.ts +++ b/frontend/composables/use-users/user-ratings.ts @@ -22,10 +22,10 @@ export const useUserSelfRatings = function () { ready.value = true; } - async function setRating(slug: string, rating: number | null, isFavorite: boolean | null) { + async function setRating(slug: string, rating: number | null, isFavorite: boolean | null, isBookmarked: boolean | null) { loading.value = true; const userId = $auth.user?.id || ""; - await api.users.setRating(userId, slug, rating, isFavorite); + await api.users.setRating(userId, slug, rating, isFavorite, isBookmarked); loading.value = false; await refreshUserRatings(); } diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index 0fe00ce552d..0ec929d1fd4 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -669,7 +669,12 @@ "no-food": "No Food" }, "reset-servings-count": "Reset Servings Count", - "not-linked-ingredients": "Additional Ingredients" + "not-linked-ingredients": "Additional Ingredients", + "add-reference": "Add Recipe Reference", + "sub-recipes": "Include Linked Recipes", + "expand-child-recipe": "Linked Recipe Ingredients", + "remove-from-wishlist": "Remove from To Cook", + "add-to-wishlist": "Add To Cook" }, "recipe-finder": { "recipe-finder": "Recipe Finder", diff --git a/frontend/lib/api/types/user.ts b/frontend/lib/api/types/user.ts index 855f64e7b27..03bf9af2563 100644 --- a/frontend/lib/api/types/user.ts +++ b/frontend/lib/api/types/user.ts @@ -234,12 +234,14 @@ export interface UserRatingCreate { recipeId: string; rating?: number | null; isFavorite?: boolean; + isBookmarked?: boolean; userId: string; } export interface UserRatingOut { recipeId: string; rating?: number | null; isFavorite?: boolean; + isBookmarked?: boolean; userId: string; id: string; } @@ -247,10 +249,12 @@ export interface UserRatingSummary { recipeId: string; rating?: number | null; isFavorite?: boolean; + isBookmarked?: boolean; } export interface UserRatingUpdate { rating?: number | null; isFavorite?: boolean | null; + isBookmarked?: boolean; } export interface ValidateResetToken { token: string; diff --git a/frontend/lib/api/user/users.ts b/frontend/lib/api/user/users.ts index a5fc8519a20..822536557b8 100644 --- a/frontend/lib/api/user/users.ts +++ b/frontend/lib/api/user/users.ts @@ -32,6 +32,9 @@ const routes = { usersIdImage: (id: string) => `${prefix}/users/${id}/image`, usersIdResetPassword: (id: string) => `${prefix}/users/${id}/reset-password`, usersId: (id: string) => `${prefix}/users/${id}`, + usersIdBookmarks: (id: string) => `${prefix}/users/${id}/bookmarks`, + usersIdBookmarksSlug: (id: string, slug: string) => `${prefix}/users/${id}/bookmarks/${slug}`, + usersSelfBookmarksId: (id: string) => `${prefix}/users/self/bookmarks/${id}`, usersIdFavorites: (id: string) => `${prefix}/users/${id}/favorites`, usersIdFavoritesSlug: (id: string, slug: string) => `${prefix}/users/${id}/favorites/${slug}`, usersIdRatings: (id: string) => `${prefix}/users/${id}/ratings`, @@ -47,6 +50,22 @@ export class UserApi extends BaseCRUDAPI { baseRoute: string = routes.users; itemRoute = (itemid: string) => routes.usersId(itemid); + async addBookmark(id: string, slug: string) { + return await this.requests.post(routes.usersIdBookmarksSlug(id, slug), {}); + } + + async removeBookmark(id: string, slug: string) { + return await this.requests.delete(routes.usersIdBookmarksSlug(id, slug)); + } + + async getBookmarks(id: string) { + return await this.requests.get(routes.usersIdBookmarks(id)); + } + + async getSelfBookmarks() { + return await this.requests.get(routes.ratingsSelf); + } + async addFavorite(id: string, slug: string) { return await this.requests.post(routes.usersIdFavoritesSlug(id, slug), {}); } @@ -67,8 +86,8 @@ export class UserApi extends BaseCRUDAPI { return await this.requests.get(routes.usersIdRatings(id)); } - async setRating(id: string, slug: string, rating: number | null, isFavorite: boolean | null) { - return await this.requests.post(routes.usersIdRatingsSlug(id, slug), { rating, isFavorite }); + async setRating(id: string, slug: string, rating: number | null, isFavorite: boolean | null, isBookmarked: boolean | null) { + return await this.requests.post(routes.usersIdRatingsSlug(id, slug), { rating, isFavorite, isBookmarked }); } async getSelfRatings() { diff --git a/frontend/lib/icons/icons.ts b/frontend/lib/icons/icons.ts index 9c3a3737397..84c686f8686 100644 --- a/frontend/lib/icons/icons.ts +++ b/frontend/lib/icons/icons.ts @@ -12,6 +12,8 @@ import { mdiTagArrowRight, mdiTagMultipleOutline, mdiShapeOutline, + mdiBookmark, + mdiBookmarkOutline, mdiBookOutline, mdiAccountCog, mdiAccountGroup, @@ -176,6 +178,8 @@ export const icons = { arrowUpDown: mdiDrag, backupRestore: mdiBackupRestore, bellAlert: mdiBellAlert, + bookmark: mdiBookmark, + bookmarkOutline: mdiBookmarkOutline, broom: mdiBroom, calendar: mdiCalendar, calendarMinus: mdiCalendarMinus, diff --git a/mealie/alembic/versions/2025-01-02-19.45.09_67abc573c341_add_is_bookmarked_to_usertorecipe.py b/mealie/alembic/versions/2025-01-02-19.45.09_67abc573c341_add_is_bookmarked_to_usertorecipe.py new file mode 100644 index 00000000000..3658ac98418 --- /dev/null +++ b/mealie/alembic/versions/2025-01-02-19.45.09_67abc573c341_add_is_bookmarked_to_usertorecipe.py @@ -0,0 +1,28 @@ +"""add is_bookmarked to UserToRecipe + +Revision ID: 67abc573c341 +Revises: b1020f328e98 +Create Date: 2025-01-02 19:45:09.045745 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "67abc573c341" +down_revision: str | None = "b1020f328e98" +branch_labels: str | tuple[str, ...] | None = None +depends_on: str | tuple[str, ...] | None = None + + +def upgrade(): + with op.batch_alter_table("users_to_recipes", schema=None) as batch_op: + batch_op.add_column(sa.Column("is_bookmarked", sa.Boolean(), nullable=False)) + batch_op.create_index(batch_op.f("ix_users_to_recipes_is_bookmarked"), ["is_bookmarked"], unique=False) + + +def downgrade(): + with op.batch_alter_table("users_to_recipes", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_users_to_recipes_is_bookmarked")) + batch_op.drop_column("is_bookmarked") diff --git a/mealie/db/models/users/user_to_recipe.py b/mealie/db/models/users/user_to_recipe.py index 363e42b5612..ad4a9546885 100644 --- a/mealie/db/models/users/user_to_recipe.py +++ b/mealie/db/models/users/user_to_recipe.py @@ -27,6 +27,7 @@ class UserToRecipe(SqlAlchemyBase, BaseMixins): rating = Column(Float, index=True, nullable=True) is_favorite = Column(Boolean, index=True, nullable=False) + is_bookmarked = Column(Boolean, index=True, nullable=False) @auto_init() def __init__(self, **_) -> None: diff --git a/mealie/repos/repository_users.py b/mealie/repos/repository_users.py index 537be987c99..d8ec76a84a0 100644 --- a/mealie/repos/repository_users.py +++ b/mealie/repos/repository_users.py @@ -79,11 +79,14 @@ class RepositoryUserRatings(GroupRepositoryGeneric[UserRatingOut, UserToRecipe]) # Since users can post events on recipes that belong to other households, # this is a group repository, rather than a household repository. - def get_by_user(self, user_id: UUID4, favorites_only=False) -> list[UserRatingOut]: + def get_by_user(self, user_id: UUID4, favorites_only=False, bookmarked_only=False) -> list[UserRatingOut]: stmt = select(UserToRecipe).filter(UserToRecipe.user_id == user_id) if favorites_only: stmt = stmt.filter(UserToRecipe.is_favorite) + if bookmarked_only: + stmt = stmt.filter(UserToRecipe.is_bookmarked) + results = self.session.execute(stmt).scalars().all() return [self.schema.model_validate(x) for x in results] diff --git a/mealie/routes/users/ratings.py b/mealie/routes/users/ratings.py index c378499994e..44f136257c0 100644 --- a/mealie/routes/users/ratings.py +++ b/mealie/routes/users/ratings.py @@ -9,7 +9,7 @@ from mealie.routes._base.routers import UserAPIRouter from mealie.routes.users._helpers import assert_user_change_allowed from mealie.schema.response.responses import ErrorResponse -from mealie.schema.user.user import UserRatingCreate, UserRatingOut, UserRatings, UserRatingUpdate +from mealie.schema.user.user import UserBookmarks, UserRatingCreate, UserRatingOut, UserRatings, UserRatingUpdate router = UserAPIRouter() @@ -51,6 +51,11 @@ async def get_favorites(self, id: UUID4): """Get user's favorited recipes""" return UserRatings(ratings=self.repos.user_ratings.get_by_user(id, favorites_only=True)) + @router.get("/{id}/bookmarks", response_model=UserRatings[UserRatingOut]) + async def get_bookmarked(self, id: UUID4): + """Get user's bookmarked recipes""" + return UserBookmarks(ratings=self.repos.user_ratings.get_by_user(id, bookmarked_only=True)) + @router.post("/{id}/ratings/{slug}") def set_rating(self, id: UUID4, slug: str, data: UserRatingUpdate): """Sets the user's rating for a recipe""" @@ -65,6 +70,7 @@ def set_rating(self, id: UUID4, slug: str, data: UserRatingUpdate): recipe_id=recipe.id, rating=data.rating, is_favorite=data.is_favorite or False, + is_bookmarked=data.is_bookmarked or False, ) ) else: @@ -72,6 +78,8 @@ def set_rating(self, id: UUID4, slug: str, data: UserRatingUpdate): user_rating.rating = data.rating if data.is_favorite is not None: user_rating.is_favorite = data.is_favorite + if data.is_bookmarked is not None: + user_rating.is_bookmarked = data.is_bookmarked self.repos.user_ratings.update(user_rating.id, user_rating) @@ -84,3 +92,13 @@ def add_favorite(self, id: UUID4, slug: str): def remove_favorite(self, id: UUID4, slug: str): """Removes a recipe from the user's favorites""" self.set_rating(id, slug, data=UserRatingUpdate(is_favorite=False)) + + @router.post("/{id}/bookmarks/{slug}") + def add_bookmarked(self, id: UUID4, slug: str): + """Adds a recipe to the user's bookmarked recipes""" + self.set_rating(id, slug, data=UserRatingUpdate(is_bookmarked=True)) + + @router.delete("/{id}/bookmarks/{slug}") + def remove_bookmarked(self, id: UUID4, slug: str): + """Removes a recipe from the user's bookmarked recipes""" + self.set_rating(id, slug, data=UserRatingUpdate(is_bookmarked=False)) diff --git a/mealie/schema/user/user.py b/mealie/schema/user/user.py index f4e1228b4b5..bb063acad98 100644 --- a/mealie/schema/user/user.py +++ b/mealie/schema/user/user.py @@ -67,6 +67,7 @@ class UserRatingSummary(MealieModel): recipe_id: UUID4 rating: float | None = None is_favorite: Annotated[bool, Field(validate_default=True)] = False + is_bookmarked: Annotated[bool, Field(validate_default=True)] = False model_config = ConfigDict(from_attributes=True) @@ -85,6 +86,7 @@ class UserRatingCreate(UserRatingSummary): class UserRatingUpdate(MealieModel): rating: float | None = None is_favorite: bool | None = None + is_bookmarked: bool | None = None class UserRatingOut(UserRatingCreate): @@ -101,6 +103,10 @@ class UserRatings(BaseModel, Generic[DataT]): ratings: list[DataT] +class UserBookmarks(BaseModel, Generic[DataT]): + recipes: list[DataT] + + class UserBase(MealieModel): id: UUID4 | None = None username: str | None = None From 1e2ab680c4ba563827cf48f88d7484ff126dfb0c Mon Sep 17 00:00:00 2001 From: Garrett Potter Date: Fri, 3 Jan 2025 20:51:49 +0000 Subject: [PATCH 2/5] Implemented bookmark logic and tests --- .../Domain/Recipe/RecipeToCookBadge.vue | 51 +++++-- frontend/lib/api/user/users.ts | 9 ++ mealie/repos/repository_users.py | 37 ++++- mealie/routes/users/crud.py | 15 ++ mealie/routes/users/ratings.py | 20 ++- mealie/schema/user/user.py | 4 - .../user_recipe_tests/test_recipe_ratings.py | 131 +++++++++++++++++- tests/utils/api_routes/__init__.py | 19 +++ 8 files changed, 267 insertions(+), 19 deletions(-) diff --git a/frontend/components/Domain/Recipe/RecipeToCookBadge.vue b/frontend/components/Domain/Recipe/RecipeToCookBadge.vue index 4bbcc9dfe7d..e4921558870 100644 --- a/frontend/components/Domain/Recipe/RecipeToCookBadge.vue +++ b/frontend/components/Domain/Recipe/RecipeToCookBadge.vue @@ -2,17 +2,17 @@ @@ -21,11 +21,12 @@ diff --git a/frontend/lib/api/user/users.ts b/frontend/lib/api/user/users.ts index 822536557b8..a3ac7f419b3 100644 --- a/frontend/lib/api/user/users.ts +++ b/frontend/lib/api/user/users.ts @@ -25,6 +25,7 @@ const prefix = "/api"; const routes = { usersSelf: `${prefix}/users/self`, ratingsSelf: `${prefix}/users/self/ratings`, + householdBookmarksSelf: `${prefix}/users/self/household/bookmarks`, passwordReset: `${prefix}/users/reset-password`, passwordChange: `${prefix}/users/password`, users: `${prefix}/users`, @@ -44,6 +45,8 @@ const routes = { usersApiTokens: `${prefix}/users/api-tokens`, usersApiTokensTokenId: (token_id: string | number) => `${prefix}/users/api-tokens/${token_id}`, + + householdBookmarks: (id: string, slug: string) => `${prefix}/users/self/household/bookmarks/${slug}` }; export class UserApi extends BaseCRUDAPI { @@ -58,6 +61,12 @@ export class UserApi extends BaseCRUDAPI { return await this.requests.delete(routes.usersIdBookmarksSlug(id, slug)); } + async getHouseholdBookmarks(id: string, slug: string) { + // const { data } = await this.requests.get(routes.householdBookmarks(id, slug)); + return await this.requests.get(routes.householdBookmarks(id, slug)); + + } + async getBookmarks(id: string) { return await this.requests.get(routes.usersIdBookmarks(id)); } diff --git a/mealie/repos/repository_users.py b/mealie/repos/repository_users.py index d8ec76a84a0..b1c835d6736 100644 --- a/mealie/repos/repository_users.py +++ b/mealie/repos/repository_users.py @@ -79,22 +79,43 @@ class RepositoryUserRatings(GroupRepositoryGeneric[UserRatingOut, UserToRecipe]) # Since users can post events on recipes that belong to other households, # this is a group repository, rather than a household repository. - def get_by_user(self, user_id: UUID4, favorites_only=False, bookmarked_only=False) -> list[UserRatingOut]: + def get_by_user(self, user_id: UUID4, favorites_only=False, bookmarks_only=False) -> list[UserRatingOut]: stmt = select(UserToRecipe).filter(UserToRecipe.user_id == user_id) if favorites_only: stmt = stmt.filter(UserToRecipe.is_favorite) - if bookmarked_only: + if bookmarks_only: stmt = stmt.filter(UserToRecipe.is_bookmarked) results = self.session.execute(stmt).scalars().all() return [self.schema.model_validate(x) for x in results] - def get_by_recipe(self, recipe_id: UUID4, favorites_only=False) -> list[UserRatingOut]: + def get_by_household( + self, household_id: UUID4, favorites_only=False, bookmarks_only=False, recipe_id: UUID4 | None = None + ) -> list[UserRatingOut]: + stmt = select(UserToRecipe).filter(UserToRecipe.household_id == household_id) + + if favorites_only: + stmt = stmt.filter(UserToRecipe.is_favorite) + + if bookmarks_only: + stmt = stmt.filter(UserToRecipe.is_bookmarked) + + if recipe_id: + stmt = stmt.filter(UserToRecipe.recipe_id == recipe_id) + + results = self.session.execute(stmt).scalars().all() + validated_results = [self.schema.model_validate(x) for x in results] + return validated_results + + def get_by_recipe(self, recipe_id: UUID4, favorites_only=False, bookmarks_only=False) -> list[UserRatingOut]: stmt = select(UserToRecipe).filter(UserToRecipe.recipe_id == recipe_id) if favorites_only: stmt = stmt.filter(UserToRecipe.is_favorite) + if bookmarks_only: + stmt = stmt.filter(UserToRecipe.is_bookmarked) + results = self.session.execute(stmt).scalars().all() return [self.schema.model_validate(x) for x in results] @@ -102,3 +123,13 @@ def get_by_user_and_recipe(self, user_id: UUID4, recipe_id: UUID4) -> UserRating stmt = select(UserToRecipe).filter(UserToRecipe.user_id == user_id, UserToRecipe.recipe_id == recipe_id) result = self.session.execute(stmt).scalars().one_or_none() return None if result is None else self.schema.model_validate(result) + + # get_by_recipe_and_household + def get_recipe_bookmarked_by_household(self, recipe_id: UUID4, household_id: UUID4) -> bool: + stmt = select(UserToRecipe).filter( + UserToRecipe.recipe_id == recipe_id, UserToRecipe.household_id == household_id, UserToRecipe.is_bookmarked + ) + results = self.session.execute(stmt).scalars().all() + if len(results) >= 1: + return True + return False diff --git a/mealie/routes/users/crud.py b/mealie/routes/users/crud.py index bdec3a20cbb..325136473ec 100644 --- a/mealie/routes/users/crud.py +++ b/mealie/routes/users/crud.py @@ -81,6 +81,21 @@ def get_logged_in_user_rating_for_recipe(self, recipe_id: UUID4): def get_logged_in_user_favorites(self): return UserRatings(ratings=self.repos.user_ratings.get_by_user(self.user.id, favorites_only=True)) + @user_router.get("/self/bookmarks", response_model=UserRatings[UserRatingSummary]) + def get_logged_in_user_bookmarks(self): + return UserRatings(ratings=self.repos.user_ratings.get_by_user(self.user.id, bookmarks_only=True)) + + @user_router.get("/self/household/bookmarks", response_model=UserRatings[UserRatingSummary]) + def get_logged_in_user_household_bookmarks(self): + return UserRatings( + ratings=self.repos.user_ratings.get_by_household(self.user.household_id, bookmarks_only=True) + ) + + @user_router.get("/self/household/bookmarks/{recipe_id}", response_model=bool) + def get_logged_in_user_household_bookmarks_for_recipe(self, recipe_id: UUID4): + response = self.repos.user_ratings.get_recipe_bookmarked_by_household(recipe_id, self.user.household_id) + return response + @user_router.put("/password") def update_password(self, password_change: ChangePassword): """Resets the User Password""" diff --git a/mealie/routes/users/ratings.py b/mealie/routes/users/ratings.py index 44f136257c0..2fdaf96a684 100644 --- a/mealie/routes/users/ratings.py +++ b/mealie/routes/users/ratings.py @@ -9,7 +9,7 @@ from mealie.routes._base.routers import UserAPIRouter from mealie.routes.users._helpers import assert_user_change_allowed from mealie.schema.response.responses import ErrorResponse -from mealie.schema.user.user import UserBookmarks, UserRatingCreate, UserRatingOut, UserRatings, UserRatingUpdate +from mealie.schema.user.user import UserRatingCreate, UserRatingOut, UserRatings, UserRatingUpdate router = UserAPIRouter() @@ -52,9 +52,9 @@ async def get_favorites(self, id: UUID4): return UserRatings(ratings=self.repos.user_ratings.get_by_user(id, favorites_only=True)) @router.get("/{id}/bookmarks", response_model=UserRatings[UserRatingOut]) - async def get_bookmarked(self, id: UUID4): + async def get_bookmarks(self, id: UUID4): """Get user's bookmarked recipes""" - return UserBookmarks(ratings=self.repos.user_ratings.get_by_user(id, bookmarked_only=True)) + return UserRatings(ratings=self.repos.user_ratings.get_by_user(id, bookmarks_only=True)) @router.post("/{id}/ratings/{slug}") def set_rating(self, id: UUID4, slug: str, data: UserRatingUpdate): @@ -102,3 +102,17 @@ def add_bookmarked(self, id: UUID4, slug: str): def remove_bookmarked(self, id: UUID4, slug: str): """Removes a recipe from the user's bookmarked recipes""" self.set_rating(id, slug, data=UserRatingUpdate(is_bookmarked=False)) + + @router.get("/{id}/household/ratings/{slug}", response_model=UserRatings[UserRatingOut]) + async def get_household_ratings(self, id: UUID4, slug: str): + """Get all household ratings""" + ratings = self.repos.user_ratings.get_by_household(self.household_id, bookmarks_only=True, recipe_id=slug) + if len(ratings) == 1: + return UserRatingOut( + recipe_id=slug, + rating=ratings["rating"], + is_favorite=ratings["is_favorite"], + is_bookmarked=ratings["is_bookmarked"], + ) + else: + return None diff --git a/mealie/schema/user/user.py b/mealie/schema/user/user.py index bb063acad98..ae35f4050a5 100644 --- a/mealie/schema/user/user.py +++ b/mealie/schema/user/user.py @@ -103,10 +103,6 @@ class UserRatings(BaseModel, Generic[DataT]): ratings: list[DataT] -class UserBookmarks(BaseModel, Generic[DataT]): - recipes: list[DataT] - - class UserBase(MealieModel): id: UUID4 | None = None username: str | None = None diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_ratings.py b/tests/integration_tests/user_recipe_tests/test_recipe_ratings.py index 8d61035c349..3dfa1f37183 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_ratings.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_ratings.py @@ -25,6 +25,7 @@ def recipes(user_tuple: tuple[TestUser, TestUser]) -> Generator[list[Recipe], No Recipe( user_id=unique_user.user_id, group_id=unique_user.group_id, + household_id=unique_user.household_id, name=slug, slug=slug, ) @@ -94,6 +95,114 @@ def test_user_recipe_favorites( assert recipe_id in favorited_recipe_ids +@pytest.mark.parametrize("use_self_route", [True, False]) +def test_user_recipe_bookmark( + api_client: TestClient, user_tuple: tuple[TestUser, TestUser], recipes: list[Recipe], use_self_route: bool +): + # we use two different users because pytest doesn't support function-scopes within parametrized tests + if use_self_route: + unique_user = user_tuple[0] + else: + unique_user = user_tuple[1] + + response = api_client.get(api_routes.users_id_bookmarks(unique_user.user_id), headers=unique_user.token) + + recipes_to_bookmark = random.sample(recipes, random_int(5, len(recipes))) + + # add bookmarks + for recipe in recipes_to_bookmark: + response = api_client.post( + api_routes.users_id_bookmarks_slug(unique_user.user_id, recipe.slug), headers=unique_user.token + ) + assert response.status_code == 200 + + if use_self_route: + get_url = api_routes.users_self_bookmarks + else: + get_url = api_routes.users_id_bookmarks(unique_user.user_id) + + response = api_client.get(get_url, headers=unique_user.token) + ratings = response.json()["ratings"] + + assert len(ratings) == len(recipes_to_bookmark) + fetched_recipe_ids = {rating["recipeId"] for rating in ratings} + bookmarked_recipe_ids = {str(recipe.id) for recipe in recipes_to_bookmark} + assert fetched_recipe_ids == bookmarked_recipe_ids + + # remove bookmarks + recipe_bookmarks_to_remove = random.sample(recipes_to_bookmark, 3) + for recipe in recipe_bookmarks_to_remove: + response = api_client.delete( + api_routes.users_id_bookmarks_slug(unique_user.user_id, recipe.slug), headers=unique_user.token + ) + assert response.status_code == 200 + + response = api_client.get(get_url, headers=unique_user.token) + ratings = response.json()["ratings"] + + assert len(ratings) == len(recipes_to_bookmark) - len(recipe_bookmarks_to_remove) + fetched_recipe_ids = {rating["recipeId"] for rating in ratings} + removed_recipe_ids = {str(recipe.id) for recipe in recipe_bookmarks_to_remove} + + for recipe_id in removed_recipe_ids: + assert recipe_id not in fetched_recipe_ids + for recipe_id in fetched_recipe_ids: + assert recipe_id in bookmarked_recipe_ids + + +def test_user_recipe_bookmark_household( + api_client: TestClient, user_tuple: tuple[TestUser, TestUser], recipes: list[Recipe] +): + usr_1, usr_2 = user_tuple + # unique_user = random.choice(user_tuple) + # h2_user = random.choice(user_tuple) + recipes_to_bookmark = random.sample(recipes, random_int(5, len(recipes))) + + # add bookmark for unique_user to first recipe + response = api_client.post( + api_routes.users_id_bookmarks_slug(usr_1.user_id, recipes_to_bookmark[0].slug), headers=usr_1.token + ) + assert response.status_code == 200 + + # add bookmark for h2_user to second recipe + response = api_client.post( + api_routes.users_id_bookmarks_slug(usr_2.user_id, recipes_to_bookmark[1].slug), headers=usr_2.token + ) + assert response.status_code == 200 + + # get bookmarks for household as usr_1 + response = api_client.get(api_routes.users_household_bookmarks, headers=usr_1.token) + ratings = response.json()["ratings"] + + assert len(ratings) == 2 + + # get bookmarks for household as usr_2 + response = api_client.get(api_routes.users_household_bookmarks, headers=usr_2.token) + ratings = response.json()["ratings"] + + assert len(ratings) == 2 + + # get bookmark by slug for household as usr_1 + response = api_client.get( + api_routes.users_self_household_bookmarks_recipe_id(recipes_to_bookmark[1].id), headers=usr_1.token + ) + assert response.status_code == 200 + bookmarked_by_household = bool(response.content) + + # assert ratings["recipeId"] == str(recipes_to_bookmark[1].id) + assert bookmarked_by_household is True + + # get bookmark by slug for household as usr_2 + response = api_client.get( + api_routes.users_self_household_bookmarks_recipe_id(recipes_to_bookmark[0].id), headers=usr_2.token + ) + assert response.status_code == 200 + bookmarked_by_household = bool(response.content) + + # assert ratings["recipeId"] == str(recipes_to_bookmark[0].id) + assert bookmarked_by_household is True + + @pytest.mark.parametrize("add_favorite", [True, False]) def test_set_user_favorite_invalid_recipe_404( api_client: TestClient, user_tuple: tuple[TestUser, TestUser], add_favorite: bool @@ -185,6 +294,25 @@ def test_set_rating_and_favorite(api_client: TestClient, user_tuple: tuple[TestU assert data["isFavorite"] is True +def test_set_rating_and_bookmark(api_client: TestClient, user_tuple: tuple[TestUser, TestUser], recipes: list[Recipe]): + unique_user = random.choice(user_tuple) + recipe = random.choice(recipes) + + rating = UserRatingUpdate(rating=random.uniform(1, 5), is_bookmarked=True) + response = api_client.post( + api_routes.users_id_ratings_slug(unique_user.user_id, recipe.slug), + json=rating.model_dump(), + headers=unique_user.token, + ) + assert response.status_code == 200 + + response = api_client.get(api_routes.users_self_ratings_recipe_id(recipe.id), headers=unique_user.token) + data = response.json() + assert data["recipeId"] == str(recipe.id) + assert data["rating"] == rating.rating + assert data["isBookmarked"] is True + + @pytest.mark.parametrize("favorite_value", [True, False]) def test_set_rating_preserve_favorite( api_client: TestClient, user_tuple: tuple[TestUser, TestUser], recipes: list[Recipe], favorite_value: bool @@ -209,7 +337,8 @@ def test_set_rating_preserve_favorite( assert data["isFavorite"] == favorite_value rating.rating = updated_rating_value - rating.is_favorite = None # this should be ignored and the favorite value should be preserved + # this should be ignored and the favorite value should be preserved + rating.is_favorite = None response = api_client.post( api_routes.users_id_ratings_slug(unique_user.user_id, recipe.slug), json=rating.model_dump(), diff --git a/tests/utils/api_routes/__init__.py b/tests/utils/api_routes/__init__.py index 9bbe656196b..58abbab0720 100644 --- a/tests/utils/api_routes/__init__.py +++ b/tests/utils/api_routes/__init__.py @@ -187,6 +187,10 @@ """`/api/users/reset-password`""" users_self = "/api/users/self" """`/api/users/self`""" +users_household_bookmarks = "/api/users/self/household/bookmarks" +"""`/api/users/self/household/bookmarks`""" +users_self_bookmarks = "/api/users/self/bookmarks" +"""`/api/users/self/bookmarks`""" users_self_favorites = "/api/users/self/favorites" """`/api/users/self/favorites`""" users_self_ratings = "/api/users/self/ratings" @@ -530,6 +534,16 @@ def users_id_favorites(id): return f"{prefix}/users/{id}/favorites" +def users_id_bookmarks(id): + """`/api/users/{id}/bookmarks`""" + return f"{prefix}/users/{id}/bookmarks" + + +def users_id_bookmarks_slug(id, slug): + """`/api/users/{id}/bookmarks/{slug}`""" + return f"{prefix}/users/{id}/bookmarks/{slug}" + + def users_id_favorites_slug(id, slug): """`/api/users/{id}/favorites/{slug}`""" return f"{prefix}/users/{id}/favorites/{slug}" @@ -558,3 +572,8 @@ def users_item_id(item_id): def users_self_ratings_recipe_id(recipe_id): """`/api/users/self/ratings/{recipe_id}`""" return f"{prefix}/users/self/ratings/{recipe_id}" + + +def users_self_household_bookmarks_recipe_id(recipe_id): + """`/api/users/self/household/bookmarks/{recipe_id}`""" + return f"{prefix}/users/self/household/bookmarks/{recipe_id}" From 14a8888b9034372d257cf59d72ad22af2f8dd9f0 Mon Sep 17 00:00:00 2001 From: Garrett Potter Date: Fri, 3 Jan 2025 20:54:52 +0000 Subject: [PATCH 3/5] Remove console log --- frontend/components/Domain/Recipe/RecipeToCookBadge.vue | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/components/Domain/Recipe/RecipeToCookBadge.vue b/frontend/components/Domain/Recipe/RecipeToCookBadge.vue index e4921558870..8264ccaa391 100644 --- a/frontend/components/Domain/Recipe/RecipeToCookBadge.vue +++ b/frontend/components/Domain/Recipe/RecipeToCookBadge.vue @@ -83,9 +83,6 @@ export default defineComponent({ const rating = await api.users.getHouseholdBookmarks($auth.user.id, props.recipeId); if (rating && rating.data) { - - - console.log("HH Flagged", rating.data); isHouseholdFlagged.value = true; forceRerender(); From dc49fedadaad1d10a2ab1ba166397c99c0cea372 Mon Sep 17 00:00:00 2001 From: Garrett Potter Date: Sat, 4 Jan 2025 16:17:38 +0000 Subject: [PATCH 4/5] Add query filter for bookmarked recipes by user --- .../Household/GroupMealPlanRuleForm.vue | 5 +++++ .../components/Domain/QueryFilterBuilder.vue | 11 ++++++++++ .../Domain/Recipe/RecipeActionMenu.vue | 4 +++- .../Domain/Recipe/RecipeOrganizerSelector.vue | 21 +++++++++++++++++-- .../Domain/Recipe/RecipeToCookBadge.vue | 2 +- .../composables/use-query-filter-builder.ts | 7 ++++--- frontend/lang/messages/en-US.json | 3 ++- frontend/lib/api/types/non-generated.ts | 4 +++- mealie/db/models/recipe/recipe.py | 6 ++++++ mealie/db/models/users/users.py | 7 +++++++ 10 files changed, 61 insertions(+), 9 deletions(-) diff --git a/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue b/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue index 259e399ad00..685d6dce7c0 100644 --- a/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue +++ b/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue @@ -133,6 +133,11 @@ export default defineComponent({ label: i18n.tc("household.households"), type: Organizer.Household, }, + { + name: "bookmarked_by.id", + label: i18n.tc("general.is-bookmarked"), + type: Organizer.User, + }, { name: "last_made", label: i18n.tc("general.last-made"), diff --git a/frontend/components/Domain/QueryFilterBuilder.vue b/frontend/components/Domain/QueryFilterBuilder.vue index b87fb24618e..e9b9abb17bd 100644 --- a/frontend/components/Domain/QueryFilterBuilder.vue +++ b/frontend/components/Domain/QueryFilterBuilder.vue @@ -211,6 +211,15 @@ :show-icon="false" @input="setOrganizerValues(field, index, $event)" /> + +