Skip to content

Commit

Permalink
feat: User-specific Recipe Ratings (#3345)
Browse files Browse the repository at this point in the history
  • Loading branch information
michael-genson authored Apr 12, 2024
1 parent 8ab09cf commit 2a541f0
Show file tree
Hide file tree
Showing 50 changed files with 1,492 additions and 438 deletions.
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

0 comments on commit 2a541f0

Please sign in to comment.