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: User-specific Recipe Ratings #3345

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
2edd7f6
fix type declaration
michael-genson Mar 18, 2024
a1651b0
remove unused param
michael-genson Mar 18, 2024
5da50e7
migrated favorites and recipes to new user_recipes table
michael-genson Mar 18, 2024
6efdf80
replaced favorite routes with user rating routes
michael-genson Mar 18, 2024
3709f5d
partial generation
michael-genson Mar 18, 2024
6888b13
generate user models
michael-genson Mar 18, 2024
4544530
add single-recipe rating route
michael-genson Mar 18, 2024
1f5a7d2
force recalculation on recipe rating any time it's set
michael-genson Mar 18, 2024
cb14ec2
fix id datatype
michael-genson Mar 19, 2024
3107d9b
removed unused prop
michael-genson Mar 19, 2024
eccb309
add back favorited by relationship with query filter
michael-genson Mar 19, 2024
1f413d6
restore favorites functionality
michael-genson Mar 19, 2024
e103d21
removed debug log
michael-genson Mar 19, 2024
a8070e5
fix several API issues
michael-genson Mar 19, 2024
f8bbf3a
replace v-rating with RecipeRating
michael-genson Mar 19, 2024
212f7c6
fix favorite API
michael-genson Mar 19, 2024
d676e20
adapted RecipeRating to prefer user rating over recipe rating
michael-genson Mar 19, 2024
aa99578
remove v-model for rating
michael-genson Mar 19, 2024
ba83641
remove zeros from average calc
michael-genson Mar 19, 2024
1cd35f5
added additional rating render logic
michael-genson Mar 19, 2024
f89dd8b
fix recipe creation rating
michael-genson Mar 19, 2024
2dc2685
added migration tests
michael-genson Mar 19, 2024
66ff600
show half ratings when not hovering
michael-genson Mar 19, 2024
aa6e8df
clean up some jank imports
michael-genson Mar 19, 2024
724ea49
lint
michael-genson Mar 19, 2024
8c7789a
rename unique constraint since we need to temporarily keep both
michael-genson Mar 19, 2024
10a4d42
Merge branch 'mealie-next' into feat/user-specific-ratings
michael-genson Mar 20, 2024
f824a0a
added tests
michael-genson Mar 20, 2024
e77babd
added special rating ordering to prefer user ratings
michael-genson Mar 20, 2024
5608307
remove unused user_id filter
michael-genson Mar 20, 2024
06d33cd
added tests
michael-genson Mar 20, 2024
4f6190e
Merge remote-tracking branch 'upstream/mealie-next' into feat/user-sp…
michael-genson Apr 9, 2024
d4e1184
Merge branch 'mealie-next' into feat/user-specific-ratings
michael-genson Apr 12, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
"""migrate favorites and ratings to user_ratings

Revision ID: d7c6efd2de42
Revises: 09aba125b57a
Create Date: 2024-03-18 02:28:15.896959

"""

from datetime import datetime
from textwrap import dedent
from typing import Any
from uuid import uuid4

import sqlalchemy as sa
from sqlalchemy import orm

import mealie.db.migration_types
from alembic import op

# revision identifiers, used by Alembic.
revision = "d7c6efd2de42"
down_revision = "09aba125b57a"
branch_labels = None
depends_on = None


def is_postgres():
return op.get_context().dialect.name == "postgresql"


def new_user_rating(user_id: Any, recipe_id: Any, rating: float | None = None, is_favorite: bool = False):
if is_postgres():
id = str(uuid4())
else:
id = "%.32x" % uuid4().int

now = datetime.now().isoformat()
return {
"id": id,
"user_id": user_id,
"recipe_id": recipe_id,
"rating": rating,
"is_favorite": is_favorite,
"created_at": now,
"update_at": now,
}


def migrate_user_favorites_to_user_ratings():
bind = op.get_bind()
session = orm.Session(bind=bind)

with session:
user_ids_and_recipe_ids = session.execute(sa.text("SELECT user_id, recipe_id FROM users_to_favorites")).all()
rows = [
new_user_rating(user_id, recipe_id, is_favorite=True)
for user_id, recipe_id in user_ids_and_recipe_ids
if user_id and recipe_id
]

if is_postgres():
query = dedent(
"""
INSERT INTO users_to_recipes (id, user_id, recipe_id, rating, is_favorite, created_at, update_at)
VALUES (:id, :user_id, :recipe_id, :rating, :is_favorite, :created_at, :update_at)
ON CONFLICT DO NOTHING
"""
)
else:
query = dedent(
"""
INSERT OR IGNORE INTO users_to_recipes
(id, user_id, recipe_id, rating, is_favorite, created_at, update_at)
VALUES (:id, :user_id, :recipe_id, :rating, :is_favorite, :created_at, :update_at)
"""
)

for row in rows:
session.execute(sa.text(query), row)


def migrate_group_to_user_ratings(group_id: Any):
bind = op.get_bind()
session = orm.Session(bind=bind)

with session:
user_ids = (
session.execute(sa.text("SELECT id FROM users WHERE group_id=:group_id").bindparams(group_id=group_id))
.scalars()
.all()
)

recipe_ids_ratings = session.execute(
sa.text(
"SELECT id, rating FROM recipes WHERE group_id=:group_id AND rating > 0 AND rating IS NOT NULL"
).bindparams(group_id=group_id)
).all()

# Convert recipe ratings to user ratings. Since we don't know who
# rated the recipe initially, we copy the rating to all users.
rows: list[dict] = []
for recipe_id, rating in recipe_ids_ratings:
for user_id in user_ids:
rows.append(new_user_rating(user_id, recipe_id, rating, is_favorite=False))

if is_postgres():
insert_query = dedent(
"""
INSERT INTO users_to_recipes (id, user_id, recipe_id, rating, is_favorite, created_at, update_at)
VALUES (:id, :user_id, :recipe_id, :rating, :is_favorite, :created_at, :update_at)
ON CONFLICT (user_id, recipe_id) DO NOTHING;
"""
)
else:
insert_query = dedent(
"""
INSERT OR IGNORE INTO users_to_recipes
(id, user_id, recipe_id, rating, is_favorite, created_at, update_at)
VALUES (:id, :user_id, :recipe_id, :rating, :is_favorite, :created_at, :update_at);
"""
)

update_query = dedent(
"""
UPDATE users_to_recipes
SET rating = :rating, update_at = :update_at
WHERE user_id = :user_id AND recipe_id = :recipe_id;
"""
)

# Create new user ratings with is_favorite set to False
for row in rows:
session.execute(sa.text(insert_query), row)

# Update existing user ratings with the correct rating
for row in rows:
session.execute(sa.text(update_query), row)


def migrate_to_user_ratings():
migrate_user_favorites_to_user_ratings()

bind = op.get_bind()
session = orm.Session(bind=bind)

with session:
group_ids = session.execute(sa.text("SELECT id FROM groups")).scalars().all()

for group_id in group_ids:
migrate_group_to_user_ratings(group_id)


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"users_to_recipes",
sa.Column("user_id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("recipe_id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("rating", sa.Float(), nullable=True),
sa.Column("is_favorite", sa.Boolean(), nullable=False),
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("update_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["recipe_id"],
["recipes.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("user_id", "recipe_id", "id"),
sa.UniqueConstraint("user_id", "recipe_id", name="user_id_recipe_id_rating_key"),
)
op.create_index(op.f("ix_users_to_recipes_created_at"), "users_to_recipes", ["created_at"], unique=False)
op.create_index(op.f("ix_users_to_recipes_is_favorite"), "users_to_recipes", ["is_favorite"], unique=False)
op.create_index(op.f("ix_users_to_recipes_rating"), "users_to_recipes", ["rating"], unique=False)
op.create_index(op.f("ix_users_to_recipes_recipe_id"), "users_to_recipes", ["recipe_id"], unique=False)
op.create_index(op.f("ix_users_to_recipes_user_id"), "users_to_recipes", ["user_id"], unique=False)

migrate_to_user_ratings()

if is_postgres():
op.drop_index("ix_users_to_favorites_recipe_id", table_name="users_to_favorites")
op.drop_index("ix_users_to_favorites_user_id", table_name="users_to_favorites")
op.alter_column("recipes", "rating", existing_type=sa.INTEGER(), type_=sa.Float(), existing_nullable=True)
else:
op.execute("DROP INDEX IF EXISTS ix_users_to_favorites_recipe_id")
op.execute("DROP INDEX IF EXISTS ix_users_to_favorites_user_id")
with op.batch_alter_table("recipes") as batch_op:
batch_op.alter_column("rating", existing_type=sa.INTEGER(), type_=sa.Float(), existing_nullable=True)

op.drop_table("users_to_favorites")
op.create_index(op.f("ix_recipes_rating"), "recipes", ["rating"], unique=False)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"recipes_ingredients", "quantity", existing_type=sa.Float(), type_=sa.INTEGER(), existing_nullable=True
)
op.drop_index(op.f("ix_recipes_rating"), table_name="recipes")
op.alter_column("recipes", "rating", existing_type=sa.Float(), type_=sa.INTEGER(), existing_nullable=True)
op.create_unique_constraint("ingredient_units_name_group_id_key", "ingredient_units", ["name", "group_id"])
op.create_unique_constraint("ingredient_foods_name_group_id_key", "ingredient_foods", ["name", "group_id"])
op.create_table(
"users_to_favorites",
sa.Column("user_id", sa.CHAR(length=32), nullable=True),
sa.Column("recipe_id", sa.CHAR(length=32), nullable=True),
sa.ForeignKeyConstraint(
["recipe_id"],
["recipes.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.UniqueConstraint("user_id", "recipe_id", name="user_id_recipe_id_key"),
)
op.create_index("ix_users_to_favorites_user_id", "users_to_favorites", ["user_id"], unique=False)
op.create_index("ix_users_to_favorites_recipe_id", "users_to_favorites", ["recipe_id"], unique=False)
op.drop_index(op.f("ix_users_to_recipes_user_id"), table_name="users_to_recipes")
op.drop_index(op.f("ix_users_to_recipes_recipe_id"), table_name="users_to_recipes")
op.drop_index(op.f("ix_users_to_recipes_rating"), table_name="users_to_recipes")
op.drop_index(op.f("ix_users_to_recipes_is_favorite"), table_name="users_to_recipes")
op.drop_index(op.f("ix_users_to_recipes_created_at"), table_name="users_to_recipes")
op.drop_table("users_to_recipes")
# ### end Alembic commands ###
2 changes: 1 addition & 1 deletion frontend/components/Domain/Group/GroupMealPlanRuleForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<script lang="ts">
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { RecipeTag, RecipeCategory } from "~/lib/api/types/group";
import { RecipeTag, RecipeCategory } from "~/lib/api/types/recipe";

export default defineComponent({
components: {
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/Domain/Recipe/RecipeActionMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

<v-spacer></v-spacer>
<div v-if="!open" class="custom-btn-group ma-1">
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :slug="recipe.slug" show-always />
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :recipe-id="recipe.id" show-always />
<RecipeTimelineBadge v-if="loggedIn" button-style :slug="recipe.slug" :recipe-name="recipe.name" />
<div v-if="loggedIn">
<v-tooltip v-if="!locked" bottom color="info">
Expand Down
8 changes: 6 additions & 2 deletions frontend/components/Domain/Recipe/RecipeCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@

<slot name="actions">
<v-card-actions v-if="showRecipeContent" class="px-1">
<RecipeFavoriteBadge v-if="isOwnGroup" class="absolute" :slug="slug" show-always />
<RecipeFavoriteBadge v-if="isOwnGroup" class="absolute" :recipe-id="recipeId" show-always />

<RecipeRating class="pb-1" :value="rating" :name="name" :slug="slug" :small="true" />
<RecipeRating class="pb-1" :value="rating" :recipe-id="recipeId" :slug="slug" :small="true" />
<v-spacer></v-spacer>
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" />

Expand Down Expand Up @@ -97,6 +97,10 @@ export default defineComponent({
required: false,
default: 0,
},
ratingColor: {
type: String,
default: "secondary",
},
image: {
type: String,
required: false,
Expand Down
17 changes: 8 additions & 9 deletions frontend/components/Domain/Recipe/RecipeCardMobile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,14 @@
</v-list-item-subtitle>
<div class="d-flex flex-wrap justify-end align-center">
<slot name="actions">
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :slug="slug" show-always />
<v-rating
v-if="showRecipeContent"
color="secondary"
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :recipe-id="recipeId" show-always />
<RecipeRating
:class="isOwnGroup ? 'ml-auto' : 'ml-auto pb-2'"
background-color="secondary lighten-3"
dense
length="5"
size="15"
:value="rating"
></v-rating>
:recipe-id="recipeId"
:slug="slug"
:small="true"
/>
<v-spacer></v-spacer>

<!-- If we're not logged-in, no items display, so we hide this menu -->
Expand Down Expand Up @@ -85,12 +82,14 @@ import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composi
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeCardImage from "./RecipeCardImage.vue";
import RecipeRating from "./RecipeRating.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";

export default defineComponent({
components: {
RecipeFavoriteBadge,
RecipeContextMenu,
RecipeRating,
RecipeCardImage,
},
props: {
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/Domain/Recipe/RecipeChips.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

<script lang="ts">
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
import { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/user";
import { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";

export type UrlPrefixParam = "tags" | "categories" | "tools";

Expand Down
15 changes: 10 additions & 5 deletions frontend/components/Domain/Recipe/RecipeFavoriteBadge.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@

<script lang="ts">
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import { useUserSelfRatings } from "~/composables/use-users";
import { useUserApi } from "~/composables/api";
import { UserOut } from "~/lib/api/types/user";
export default defineComponent({
props: {
slug: {
recipeId: {
type: String,
default: "",
},
Expand All @@ -42,19 +43,23 @@ export default defineComponent({
setup(props) {
const api = useUserApi();
const { $auth } = useContext();
const { userRatings, refreshUserRatings } = useUserSelfRatings();

// 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 isFavorite = computed(() => user.value?.favoriteRecipes?.includes(props.slug));
const isFavorite = computed(() => {
const rating = userRatings.value.find((r) => r.recipeId === props.recipeId);
return rating?.isFavorite || false;
});

async function toggleFavorite() {
if (!isFavorite.value) {
await api.users.addFavorite(user.value?.id, props.slug);
await api.users.addFavorite(user.value?.id, props.recipeId);
} else {
await api.users.removeFavorite(user.value?.id, props.slug);
await api.users.removeFavorite(user.value?.id, props.recipeId);
}
$auth.fetchUser();
await refreshUserRatings();
}

return { isFavorite, toggleFavorite };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
<script lang="ts">
import { defineComponent, ref, useContext, computed, onMounted } from "@nuxtjs/composition-api";
import RecipeOrganizerDialog from "./RecipeOrganizerDialog.vue";
import { RecipeCategory, RecipeTag } from "~/lib/api/types/user";
import { RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
import { RecipeTool } from "~/lib/api/types/admin";
import { useTagStore } from "~/composables/store/use-tag-store";
import { useCategoryStore, useToolStore } from "~/composables/store";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<v-card-text>
<v-card-title class="headline pa-0 flex-column align-center">
{{ recipe.name }}
<RecipeRating :key="recipe.slug" v-model="recipe.rating" :name="recipe.name" :slug="recipe.slug" />
<RecipeRating :key="recipe.slug" :value="recipe.rating" :recipe-id="recipe.id" :slug="recipe.slug" />
</v-card-title>
<v-divider class="my-2"></v-divider>
<SafeMarkdown :source="recipe.description" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
v-if="landscape && $vuetify.breakpoint.smAndUp"
:key="recipe.slug"
v-model="recipe.rating"
:name="recipe.name"
:recipe-id="recipe.id"
:slug="recipe.slug"
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
v-if="$vuetify.breakpoint.smAndDown"
:key="recipe.slug"
v-model="recipe.rating"
:name="recipe.name"
:recipe-id="recipe.id"
:slug="recipe.slug"
/>
</div>
Expand Down
Loading
Loading