Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: Recipe bookmarks flag to cook #4830

Draft
wants to merge 5 commits into
base: mealie-next
Choose a base branch
from
Draft
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Implemented bookmark logic and tests
parumpum committed Jan 3, 2025
commit 1e2ab680c4ba563827cf48f88d7484ff126dfb0c
51 changes: 43 additions & 8 deletions frontend/components/Domain/Recipe/RecipeToCookBadge.vue
Original file line number Diff line number Diff line change
@@ -2,17 +2,17 @@
<v-tooltip bottom nudge-right="50" :color="buttonStyle ? 'info' : 'secondary'">
<template #activator="{ on, attrs }">
<v-btn
v-if="isFlagged || showAlways"
v-bind="attrs"

small
:color="buttonStyle ? 'info' : 'secondary'"
:icon="!buttonStyle"
:fab="buttonStyle"
v-bind="attrs"
@click.prevent="toggleWantToCook"
v-on="on"
>
<v-icon :small="false" :color="buttonStyle ? 'white' : 'secondary'">
{{ isFlagged ? $globals.icons.bookmark : $globals.icons.bookmarkOutline }}
<v-icon :key="componentKey" :small="false" :color="isFlagged ? 'secondary' : 'grey darken-1'">
{{ isFlagged ? $globals.icons.bookmark : isHouseholdFlagged ? $globals.icons.bookmark : $globals.icons.bookmarkOutline }}
</v-icon>
</v-btn>
</template>
@@ -21,11 +21,12 @@
</template>

<script lang="ts">
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import { computed, defineComponent, onMounted, ref, useContext } from "@nuxtjs/composition-api";
import { useUserSelfRatings } from "~/composables/use-users";
import { useUserApi } from "~/composables/api";
import { UserOut } from "~/lib/api/types/user";
import { useLoggedInState } from "~/composables/use-logged-in-state";

export default defineComponent({
props: {
recipeId: {
@@ -49,17 +50,51 @@ export default defineComponent({
const api = useUserApi();
const { $auth } = useContext();
const { userRatings, refreshUserRatings, setRating } = useUserSelfRatings();
const { isOwnGroup } = useLoggedInState();

const ready = ref(false);
const componentKey = ref(0);
// TODO Setup the correct type for $auth.user
// See https://github.com/nuxt-community/auth-module/issues/1097
const user = computed(() => $auth.user as unknown as UserOut);
const isHouseholdFlagged = ref(false)

function forceRerender() {
componentKey.value += 1;
}

const isFlagged = computed(() => {
const rating = userRatings.value.find((r) => r.recipeId === props.recipeId);
return rating?.isBookmarked || false;
if (rating?.isBookmarked) {
return true;
}
return false;
});


onMounted(async () => {
await checkHouseholdFlagged();
ready.value = true;
});

async function checkHouseholdFlagged() {
if (!$auth.user) {
return;
}

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();

} else {
isHouseholdFlagged.value = false;
}

}

async function toggleWantToCook() {
if (!isFlagged.value) {
await api.users.addBookmark(user.value?.id, props.recipeId);
@@ -69,7 +104,7 @@ export default defineComponent({
await refreshUserRatings();
}

return { isFlagged, toggleWantToCook };
return { isFlagged, toggleWantToCook, isHouseholdFlagged, componentKey };
},
});
</script>
9 changes: 9 additions & 0 deletions frontend/lib/api/user/users.ts
Original file line number Diff line number Diff line change
@@ -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<UserIn, UserOut, UserBase> {
@@ -58,6 +61,12 @@ export class UserApi extends BaseCRUDAPI<UserIn, UserOut, UserBase> {
return await this.requests.delete(routes.usersIdBookmarksSlug(id, slug));
}

async getHouseholdBookmarks(id: string, slug: string) {
// const { data } = await this.requests.get<UserRatingsOut>(routes.householdBookmarks(id, slug));
return await this.requests.get<boolean>(routes.householdBookmarks(id, slug));

}

async getBookmarks(id: string) {
return await this.requests.get<UserRatingsOut>(routes.usersIdBookmarks(id));
}
37 changes: 34 additions & 3 deletions mealie/repos/repository_users.py
Original file line number Diff line number Diff line change
@@ -79,26 +79,57 @@ 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]

def get_by_user_and_recipe(self, user_id: UUID4, recipe_id: UUID4) -> UserRatingOut | None:
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
15 changes: 15 additions & 0 deletions mealie/routes/users/crud.py
Original file line number Diff line number Diff line change
@@ -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"""
20 changes: 17 additions & 3 deletions mealie/routes/users/ratings.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 0 additions & 4 deletions mealie/schema/user/user.py
Original file line number Diff line number Diff line change
@@ -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
131 changes: 130 additions & 1 deletion tests/integration_tests/user_recipe_tests/test_recipe_ratings.py
Original file line number Diff line number Diff line change
@@ -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(),
19 changes: 19 additions & 0 deletions tests/utils/api_routes/__init__.py
Original file line number Diff line number Diff line change
@@ -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}"