Skip to content

Commit 2a541f0

Browse files
feat: User-specific Recipe Ratings (#3345)
1 parent 8ab09cf commit 2a541f0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1492
-438
lines changed
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"""migrate favorites and ratings to user_ratings
2+
3+
Revision ID: d7c6efd2de42
4+
Revises: 09aba125b57a
5+
Create Date: 2024-03-18 02:28:15.896959
6+
7+
"""
8+
9+
from datetime import datetime
10+
from textwrap import dedent
11+
from typing import Any
12+
from uuid import uuid4
13+
14+
import sqlalchemy as sa
15+
from sqlalchemy import orm
16+
17+
import mealie.db.migration_types
18+
from alembic import op
19+
20+
# revision identifiers, used by Alembic.
21+
revision = "d7c6efd2de42"
22+
down_revision = "09aba125b57a"
23+
branch_labels = None
24+
depends_on = None
25+
26+
27+
def is_postgres():
28+
return op.get_context().dialect.name == "postgresql"
29+
30+
31+
def new_user_rating(user_id: Any, recipe_id: Any, rating: float | None = None, is_favorite: bool = False):
32+
if is_postgres():
33+
id = str(uuid4())
34+
else:
35+
id = "%.32x" % uuid4().int
36+
37+
now = datetime.now().isoformat()
38+
return {
39+
"id": id,
40+
"user_id": user_id,
41+
"recipe_id": recipe_id,
42+
"rating": rating,
43+
"is_favorite": is_favorite,
44+
"created_at": now,
45+
"update_at": now,
46+
}
47+
48+
49+
def migrate_user_favorites_to_user_ratings():
50+
bind = op.get_bind()
51+
session = orm.Session(bind=bind)
52+
53+
with session:
54+
user_ids_and_recipe_ids = session.execute(sa.text("SELECT user_id, recipe_id FROM users_to_favorites")).all()
55+
rows = [
56+
new_user_rating(user_id, recipe_id, is_favorite=True)
57+
for user_id, recipe_id in user_ids_and_recipe_ids
58+
if user_id and recipe_id
59+
]
60+
61+
if is_postgres():
62+
query = dedent(
63+
"""
64+
INSERT INTO users_to_recipes (id, user_id, recipe_id, rating, is_favorite, created_at, update_at)
65+
VALUES (:id, :user_id, :recipe_id, :rating, :is_favorite, :created_at, :update_at)
66+
ON CONFLICT DO NOTHING
67+
"""
68+
)
69+
else:
70+
query = dedent(
71+
"""
72+
INSERT OR IGNORE INTO users_to_recipes
73+
(id, user_id, recipe_id, rating, is_favorite, created_at, update_at)
74+
VALUES (:id, :user_id, :recipe_id, :rating, :is_favorite, :created_at, :update_at)
75+
"""
76+
)
77+
78+
for row in rows:
79+
session.execute(sa.text(query), row)
80+
81+
82+
def migrate_group_to_user_ratings(group_id: Any):
83+
bind = op.get_bind()
84+
session = orm.Session(bind=bind)
85+
86+
with session:
87+
user_ids = (
88+
session.execute(sa.text("SELECT id FROM users WHERE group_id=:group_id").bindparams(group_id=group_id))
89+
.scalars()
90+
.all()
91+
)
92+
93+
recipe_ids_ratings = session.execute(
94+
sa.text(
95+
"SELECT id, rating FROM recipes WHERE group_id=:group_id AND rating > 0 AND rating IS NOT NULL"
96+
).bindparams(group_id=group_id)
97+
).all()
98+
99+
# Convert recipe ratings to user ratings. Since we don't know who
100+
# rated the recipe initially, we copy the rating to all users.
101+
rows: list[dict] = []
102+
for recipe_id, rating in recipe_ids_ratings:
103+
for user_id in user_ids:
104+
rows.append(new_user_rating(user_id, recipe_id, rating, is_favorite=False))
105+
106+
if is_postgres():
107+
insert_query = dedent(
108+
"""
109+
INSERT INTO users_to_recipes (id, user_id, recipe_id, rating, is_favorite, created_at, update_at)
110+
VALUES (:id, :user_id, :recipe_id, :rating, :is_favorite, :created_at, :update_at)
111+
ON CONFLICT (user_id, recipe_id) DO NOTHING;
112+
"""
113+
)
114+
else:
115+
insert_query = dedent(
116+
"""
117+
INSERT OR IGNORE INTO users_to_recipes
118+
(id, user_id, recipe_id, rating, is_favorite, created_at, update_at)
119+
VALUES (:id, :user_id, :recipe_id, :rating, :is_favorite, :created_at, :update_at);
120+
"""
121+
)
122+
123+
update_query = dedent(
124+
"""
125+
UPDATE users_to_recipes
126+
SET rating = :rating, update_at = :update_at
127+
WHERE user_id = :user_id AND recipe_id = :recipe_id;
128+
"""
129+
)
130+
131+
# Create new user ratings with is_favorite set to False
132+
for row in rows:
133+
session.execute(sa.text(insert_query), row)
134+
135+
# Update existing user ratings with the correct rating
136+
for row in rows:
137+
session.execute(sa.text(update_query), row)
138+
139+
140+
def migrate_to_user_ratings():
141+
migrate_user_favorites_to_user_ratings()
142+
143+
bind = op.get_bind()
144+
session = orm.Session(bind=bind)
145+
146+
with session:
147+
group_ids = session.execute(sa.text("SELECT id FROM groups")).scalars().all()
148+
149+
for group_id in group_ids:
150+
migrate_group_to_user_ratings(group_id)
151+
152+
153+
def upgrade():
154+
# ### commands auto generated by Alembic - please adjust! ###
155+
op.create_table(
156+
"users_to_recipes",
157+
sa.Column("user_id", mealie.db.migration_types.GUID(), nullable=False),
158+
sa.Column("recipe_id", mealie.db.migration_types.GUID(), nullable=False),
159+
sa.Column("rating", sa.Float(), nullable=True),
160+
sa.Column("is_favorite", sa.Boolean(), nullable=False),
161+
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
162+
sa.Column("created_at", sa.DateTime(), nullable=True),
163+
sa.Column("update_at", sa.DateTime(), nullable=True),
164+
sa.ForeignKeyConstraint(
165+
["recipe_id"],
166+
["recipes.id"],
167+
),
168+
sa.ForeignKeyConstraint(
169+
["user_id"],
170+
["users.id"],
171+
),
172+
sa.PrimaryKeyConstraint("user_id", "recipe_id", "id"),
173+
sa.UniqueConstraint("user_id", "recipe_id", name="user_id_recipe_id_rating_key"),
174+
)
175+
op.create_index(op.f("ix_users_to_recipes_created_at"), "users_to_recipes", ["created_at"], unique=False)
176+
op.create_index(op.f("ix_users_to_recipes_is_favorite"), "users_to_recipes", ["is_favorite"], unique=False)
177+
op.create_index(op.f("ix_users_to_recipes_rating"), "users_to_recipes", ["rating"], unique=False)
178+
op.create_index(op.f("ix_users_to_recipes_recipe_id"), "users_to_recipes", ["recipe_id"], unique=False)
179+
op.create_index(op.f("ix_users_to_recipes_user_id"), "users_to_recipes", ["user_id"], unique=False)
180+
181+
migrate_to_user_ratings()
182+
183+
if is_postgres():
184+
op.drop_index("ix_users_to_favorites_recipe_id", table_name="users_to_favorites")
185+
op.drop_index("ix_users_to_favorites_user_id", table_name="users_to_favorites")
186+
op.alter_column("recipes", "rating", existing_type=sa.INTEGER(), type_=sa.Float(), existing_nullable=True)
187+
else:
188+
op.execute("DROP INDEX IF EXISTS ix_users_to_favorites_recipe_id")
189+
op.execute("DROP INDEX IF EXISTS ix_users_to_favorites_user_id")
190+
with op.batch_alter_table("recipes") as batch_op:
191+
batch_op.alter_column("rating", existing_type=sa.INTEGER(), type_=sa.Float(), existing_nullable=True)
192+
193+
op.drop_table("users_to_favorites")
194+
op.create_index(op.f("ix_recipes_rating"), "recipes", ["rating"], unique=False)
195+
# ### end Alembic commands ###
196+
197+
198+
def downgrade():
199+
# ### commands auto generated by Alembic - please adjust! ###
200+
op.alter_column(
201+
"recipes_ingredients", "quantity", existing_type=sa.Float(), type_=sa.INTEGER(), existing_nullable=True
202+
)
203+
op.drop_index(op.f("ix_recipes_rating"), table_name="recipes")
204+
op.alter_column("recipes", "rating", existing_type=sa.Float(), type_=sa.INTEGER(), existing_nullable=True)
205+
op.create_unique_constraint("ingredient_units_name_group_id_key", "ingredient_units", ["name", "group_id"])
206+
op.create_unique_constraint("ingredient_foods_name_group_id_key", "ingredient_foods", ["name", "group_id"])
207+
op.create_table(
208+
"users_to_favorites",
209+
sa.Column("user_id", sa.CHAR(length=32), nullable=True),
210+
sa.Column("recipe_id", sa.CHAR(length=32), nullable=True),
211+
sa.ForeignKeyConstraint(
212+
["recipe_id"],
213+
["recipes.id"],
214+
),
215+
sa.ForeignKeyConstraint(
216+
["user_id"],
217+
["users.id"],
218+
),
219+
sa.UniqueConstraint("user_id", "recipe_id", name="user_id_recipe_id_key"),
220+
)
221+
op.create_index("ix_users_to_favorites_user_id", "users_to_favorites", ["user_id"], unique=False)
222+
op.create_index("ix_users_to_favorites_recipe_id", "users_to_favorites", ["recipe_id"], unique=False)
223+
op.drop_index(op.f("ix_users_to_recipes_user_id"), table_name="users_to_recipes")
224+
op.drop_index(op.f("ix_users_to_recipes_recipe_id"), table_name="users_to_recipes")
225+
op.drop_index(op.f("ix_users_to_recipes_rating"), table_name="users_to_recipes")
226+
op.drop_index(op.f("ix_users_to_recipes_is_favorite"), table_name="users_to_recipes")
227+
op.drop_index(op.f("ix_users_to_recipes_created_at"), table_name="users_to_recipes")
228+
op.drop_table("users_to_recipes")
229+
# ### end Alembic commands ###

frontend/components/Domain/Group/GroupMealPlanRuleForm.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
<script lang="ts">
2020
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
2121
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
22-
import { RecipeTag, RecipeCategory } from "~/lib/api/types/group";
22+
import { RecipeTag, RecipeCategory } from "~/lib/api/types/recipe";
2323
2424
export default defineComponent({
2525
components: {

frontend/components/Domain/Recipe/RecipeActionMenu.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
<v-spacer></v-spacer>
2323
<div v-if="!open" class="custom-btn-group ma-1">
24-
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :slug="recipe.slug" show-always />
24+
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :recipe-id="recipe.id" show-always />
2525
<RecipeTimelineBadge v-if="loggedIn" button-style :slug="recipe.slug" :recipe-name="recipe.name" />
2626
<div v-if="loggedIn">
2727
<v-tooltip v-if="!locked" bottom color="info">

frontend/components/Domain/Recipe/RecipeCard.vue

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@
3535

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

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

@@ -97,6 +97,10 @@ export default defineComponent({
9797
required: false,
9898
default: 0,
9999
},
100+
ratingColor: {
101+
type: String,
102+
default: "secondary",
103+
},
100104
image: {
101105
type: String,
102106
required: false,

frontend/components/Domain/Recipe/RecipeCardMobile.vue

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,14 @@
3838
</v-list-item-subtitle>
3939
<div class="d-flex flex-wrap justify-end align-center">
4040
<slot name="actions">
41-
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :slug="slug" show-always />
42-
<v-rating
43-
v-if="showRecipeContent"
44-
color="secondary"
41+
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :recipe-id="recipeId" show-always />
42+
<RecipeRating
4543
:class="isOwnGroup ? 'ml-auto' : 'ml-auto pb-2'"
46-
background-color="secondary lighten-3"
47-
dense
48-
length="5"
49-
size="15"
5044
:value="rating"
51-
></v-rating>
45+
:recipe-id="recipeId"
46+
:slug="slug"
47+
:small="true"
48+
/>
5249
<v-spacer></v-spacer>
5350

5451
<!-- If we're not logged-in, no items display, so we hide this menu -->
@@ -85,12 +82,14 @@ import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composi
8582
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
8683
import RecipeContextMenu from "./RecipeContextMenu.vue";
8784
import RecipeCardImage from "./RecipeCardImage.vue";
85+
import RecipeRating from "./RecipeRating.vue";
8886
import { useLoggedInState } from "~/composables/use-logged-in-state";
8987
9088
export default defineComponent({
9189
components: {
9290
RecipeFavoriteBadge,
9391
RecipeContextMenu,
92+
RecipeRating,
9493
RecipeCardImage,
9594
},
9695
props: {

frontend/components/Domain/Recipe/RecipeChips.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
<script lang="ts">
2020
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
21-
import { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/user";
21+
import { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
2222
2323
export type UrlPrefixParam = "tags" | "categories" | "tools";
2424

frontend/components/Domain/Recipe/RecipeFavoriteBadge.vue

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@
2222

2323
<script lang="ts">
2424
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
25+
import { useUserSelfRatings } from "~/composables/use-users";
2526
import { useUserApi } from "~/composables/api";
2627
import { UserOut } from "~/lib/api/types/user";
2728
export default defineComponent({
2829
props: {
29-
slug: {
30+
recipeId: {
3031
type: String,
3132
default: "",
3233
},
@@ -42,19 +43,23 @@ export default defineComponent({
4243
setup(props) {
4344
const api = useUserApi();
4445
const { $auth } = useContext();
46+
const { userRatings, refreshUserRatings } = useUserSelfRatings();
4547
4648
// TODO Setup the correct type for $auth.user
4749
// See https://github.com/nuxt-community/auth-module/issues/1097
4850
const user = computed(() => $auth.user as unknown as UserOut);
49-
const isFavorite = computed(() => user.value?.favoriteRecipes?.includes(props.slug));
51+
const isFavorite = computed(() => {
52+
const rating = userRatings.value.find((r) => r.recipeId === props.recipeId);
53+
return rating?.isFavorite || false;
54+
});
5055
5156
async function toggleFavorite() {
5257
if (!isFavorite.value) {
53-
await api.users.addFavorite(user.value?.id, props.slug);
58+
await api.users.addFavorite(user.value?.id, props.recipeId);
5459
} else {
55-
await api.users.removeFavorite(user.value?.id, props.slug);
60+
await api.users.removeFavorite(user.value?.id, props.recipeId);
5661
}
57-
$auth.fetchUser();
62+
await refreshUserRatings();
5863
}
5964
6065
return { isFavorite, toggleFavorite };

frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
<script lang="ts">
4747
import { defineComponent, ref, useContext, computed, onMounted } from "@nuxtjs/composition-api";
4848
import RecipeOrganizerDialog from "./RecipeOrganizerDialog.vue";
49-
import { RecipeCategory, RecipeTag } from "~/lib/api/types/user";
49+
import { RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
5050
import { RecipeTool } from "~/lib/api/types/admin";
5151
import { useTagStore } from "~/composables/store/use-tag-store";
5252
import { useCategoryStore, useToolStore } from "~/composables/store";

frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<v-card-text>
66
<v-card-title class="headline pa-0 flex-column align-center">
77
{{ recipe.name }}
8-
<RecipeRating :key="recipe.slug" v-model="recipe.rating" :name="recipe.name" :slug="recipe.slug" />
8+
<RecipeRating :key="recipe.slug" :value="recipe.rating" :recipe-id="recipe.id" :slug="recipe.slug" />
99
</v-card-title>
1010
<v-divider class="my-2"></v-divider>
1111
<SafeMarkdown :source="recipe.description" />

frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
v-if="landscape && $vuetify.breakpoint.smAndUp"
2121
:key="recipe.slug"
2222
v-model="recipe.rating"
23-
:name="recipe.name"
23+
:recipe-id="recipe.id"
2424
:slug="recipe.slug"
2525
/>
2626
</div>

frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageTitleContent.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
v-if="$vuetify.breakpoint.smAndDown"
2525
:key="recipe.slug"
2626
v-model="recipe.rating"
27-
:name="recipe.name"
27+
:recipe-id="recipe.id"
2828
:slug="recipe.slug"
2929
/>
3030
</div>

0 commit comments

Comments
 (0)