mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-24 01:12:54 -04:00
feat: User-specific Recipe Ratings (#3345)
This commit is contained in:
parent
8ab09cf03b
commit
2a541f081a
@ -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 ###
|
@ -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: {
|
||||
|
@ -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">
|
||||
|
@ -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" />
|
||||
|
||||
@ -97,6 +97,10 @@ export default defineComponent({
|
||||
required: false,
|
||||
default: 0,
|
||||
},
|
||||
ratingColor: {
|
||||
type: String,
|
||||
default: "secondary",
|
||||
},
|
||||
image: {
|
||||
type: String,
|
||||
required: false,
|
||||
|
@ -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 -->
|
||||
@ -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: {
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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: "",
|
||||
},
|
||||
@ -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 };
|
||||
|
@ -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";
|
||||
|
@ -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" />
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -1,34 +1,35 @@
|
||||
<template>
|
||||
<div @click.prevent>
|
||||
<v-rating
|
||||
v-model="rating"
|
||||
:readonly="!isOwnGroup"
|
||||
color="secondary"
|
||||
background-color="secondary lighten-3"
|
||||
length="5"
|
||||
:dense="small ? true : undefined"
|
||||
:size="small ? 15 : undefined"
|
||||
hover
|
||||
:value="value"
|
||||
clearable
|
||||
@input="updateRating"
|
||||
@click="updateRating"
|
||||
></v-rating>
|
||||
<v-hover v-slot="{ hover }">
|
||||
<v-rating
|
||||
:value="rating.ratingValue"
|
||||
:half-increments="(!hover) || (!isOwnGroup)"
|
||||
:readonly="!isOwnGroup"
|
||||
:color="hover ? attrs.hoverColor : attrs.color"
|
||||
:background-color="attrs.backgroundColor"
|
||||
length="5"
|
||||
:dense="small ? true : undefined"
|
||||
:size="small ? 15 : undefined"
|
||||
hover
|
||||
clearable
|
||||
@input="updateRating"
|
||||
@click="updateRating"
|
||||
/>
|
||||
</v-hover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from "@nuxtjs/composition-api";
|
||||
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { useUserSelfRatings } from "~/composables/use-users";
|
||||
export default defineComponent({
|
||||
props: {
|
||||
emitOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// TODO Remove name prop?
|
||||
name: {
|
||||
recipeId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
@ -44,26 +45,79 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
preferGroupRating: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const { $auth } = useContext();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const { userRatings, setRating, ready: ratingsLoaded } = useUserSelfRatings();
|
||||
const hideGroupRating = ref(false);
|
||||
|
||||
const rating = ref(props.value);
|
||||
type Rating = {
|
||||
ratingValue: number | undefined;
|
||||
hasUserRating: boolean | undefined
|
||||
};
|
||||
|
||||
const api = useUserApi();
|
||||
function updateRating(val: number | null) {
|
||||
if (val === 0) {
|
||||
val = null;
|
||||
// prefer user rating over group rating
|
||||
const rating = computed<Rating>(() => {
|
||||
if (!ratingsLoaded.value) {
|
||||
return { ratingValue: undefined, hasUserRating: undefined };
|
||||
}
|
||||
if (!($auth.user?.id) || props.preferGroupRating) {
|
||||
return { ratingValue: props.value, hasUserRating: false };
|
||||
}
|
||||
|
||||
const userRating = userRatings.value.find((r) => r.recipeId === props.recipeId);
|
||||
return {
|
||||
ratingValue: userRating?.rating || (hideGroupRating.value ? 0 : props.value),
|
||||
hasUserRating: !!userRating?.rating
|
||||
};
|
||||
});
|
||||
|
||||
// if a user unsets their rating, we don't want to fall back to the group rating since it's out of sync
|
||||
watch(
|
||||
() => rating.value.hasUserRating,
|
||||
() => {
|
||||
if (rating.value.hasUserRating && !props.preferGroupRating) {
|
||||
hideGroupRating.value = true;
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const attrs = computed(() => {
|
||||
return isOwnGroup.value ? {
|
||||
// Logged-in user
|
||||
color: rating.value.hasUserRating ? "secondary" : "grey darken-1",
|
||||
hoverColor: "secondary",
|
||||
backgroundColor: "secondary lighten-3",
|
||||
} : {
|
||||
// Anonymous user
|
||||
color: "secondary",
|
||||
hoverColor: "secondary",
|
||||
backgroundColor: "secondary lighten-3",
|
||||
};
|
||||
})
|
||||
|
||||
function updateRating(val: number | null) {
|
||||
if (!isOwnGroup.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.emitOnly) {
|
||||
api.recipes.patchOne(props.slug, {
|
||||
rating: val,
|
||||
});
|
||||
setRating(props.slug, val || 0, null);
|
||||
}
|
||||
context.emit("input", val);
|
||||
}
|
||||
|
||||
return { isOwnGroup, rating, updateRating };
|
||||
return {
|
||||
attrs,
|
||||
isOwnGroup,
|
||||
rating,
|
||||
updateRating,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
@ -2,7 +2,7 @@ import { reactive, ref, useAsync } from "@nuxtjs/composition-api";
|
||||
import { useAsyncKey } from "../use-utils";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { VForm } from "~/types/vuetify";
|
||||
import { RecipeTool } from "~/lib/api/types/user";
|
||||
import { RecipeTool } from "~/lib/api/types/recipe";
|
||||
|
||||
export const useTools = function (eager = true) {
|
||||
const workingToolData = reactive<RecipeTool>({
|
||||
|
@ -2,7 +2,7 @@ import { reactive, ref, Ref } from "@nuxtjs/composition-api";
|
||||
import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory";
|
||||
import { usePublicExploreApi } from "../api/api-client";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { RecipeCategory } from "~/lib/api/types/admin";
|
||||
import { RecipeCategory } from "~/lib/api/types/recipe";
|
||||
|
||||
const categoryStore: Ref<RecipeCategory[]> = ref([]);
|
||||
const publicStoreLoading = ref(false);
|
||||
|
@ -1,2 +1,3 @@
|
||||
export { useUserForm } from "./user-form";
|
||||
export { useUserRegistrationForm } from "./user-registration-form";
|
||||
export { useUserSelfRatings } from "./user-ratings";
|
||||
|
40
frontend/composables/use-users/user-ratings.ts
Normal file
40
frontend/composables/use-users/user-ratings.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { ref, useContext } from "@nuxtjs/composition-api";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { UserRatingSummary } from "~/lib/api/types/user";
|
||||
|
||||
const userRatings = ref<UserRatingSummary[]>([]);
|
||||
const loading = ref(false);
|
||||
const ready = ref(false);
|
||||
|
||||
export const useUserSelfRatings = function () {
|
||||
const { $auth } = useContext();
|
||||
const api = useUserApi();
|
||||
|
||||
async function refreshUserRatings() {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
const { data } = await api.users.getSelfRatings();
|
||||
userRatings.value = data?.ratings || [];
|
||||
loading.value = false;
|
||||
ready.value = true;
|
||||
}
|
||||
|
||||
async function setRating(slug: string, rating: number | null, isFavorite: boolean | null) {
|
||||
loading.value = true;
|
||||
const userId = $auth.user?.id || "";
|
||||
await api.users.setRating(userId, slug, rating, isFavorite);
|
||||
loading.value = false;
|
||||
await refreshUserRatings();
|
||||
}
|
||||
|
||||
refreshUserRatings();
|
||||
return {
|
||||
userRatings,
|
||||
refreshUserRatings,
|
||||
setRating,
|
||||
ready,
|
||||
}
|
||||
}
|
@ -5,10 +5,11 @@
|
||||
/* Do not modify it by hand - just update the pydantic models and then re-run the script
|
||||
*/
|
||||
|
||||
export type WebhookType = "mealplan";
|
||||
export type AuthMethod = "Mealie" | "LDAP" | "OIDC";
|
||||
|
||||
export interface ChangePassword {
|
||||
currentPassword: string;
|
||||
currentPassword?: string;
|
||||
newPassword: string;
|
||||
}
|
||||
export interface CreateToken {
|
||||
@ -30,6 +31,11 @@ export interface CreateUserRegistration {
|
||||
seedData?: boolean;
|
||||
locale?: string;
|
||||
}
|
||||
export interface CredentialsRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
remember_me?: boolean;
|
||||
}
|
||||
export interface DeleteTokenResponse {
|
||||
tokenDelete: string;
|
||||
}
|
||||
@ -44,7 +50,7 @@ export interface GroupInDB {
|
||||
id: string;
|
||||
slug: string;
|
||||
categories?: CategoryBase[];
|
||||
webhooks?: unknown[];
|
||||
webhooks?: ReadWebhook[];
|
||||
users?: UserOut[];
|
||||
preferences?: ReadGroupPreferences;
|
||||
}
|
||||
@ -60,7 +66,17 @@ export interface CategoryBase {
|
||||
id: string;
|
||||
slug: string;
|
||||
}
|
||||
export interface ReadWebhook {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
webhookType?: WebhookType & string;
|
||||
scheduledTime: string;
|
||||
groupId: string;
|
||||
id: string;
|
||||
}
|
||||
export interface UserOut {
|
||||
id: string;
|
||||
username?: string;
|
||||
fullName?: string;
|
||||
email: string;
|
||||
@ -68,11 +84,9 @@ export interface UserOut {
|
||||
admin?: boolean;
|
||||
group: string;
|
||||
advanced?: boolean;
|
||||
favoriteRecipes?: string[];
|
||||
canInvite?: boolean;
|
||||
canManage?: boolean;
|
||||
canOrganize?: boolean;
|
||||
id: string;
|
||||
groupId: string;
|
||||
groupSlug: string;
|
||||
tokens?: LongLiveTokenOut[];
|
||||
@ -109,6 +123,7 @@ export interface LongLiveTokenInDB {
|
||||
user: PrivateUser;
|
||||
}
|
||||
export interface PrivateUser {
|
||||
id: string;
|
||||
username?: string;
|
||||
fullName?: string;
|
||||
email: string;
|
||||
@ -116,11 +131,9 @@ export interface PrivateUser {
|
||||
admin?: boolean;
|
||||
group: string;
|
||||
advanced?: boolean;
|
||||
favoriteRecipes?: string[];
|
||||
canInvite?: boolean;
|
||||
canManage?: boolean;
|
||||
canOrganize?: boolean;
|
||||
id: string;
|
||||
groupId: string;
|
||||
groupSlug: string;
|
||||
tokens?: LongLiveTokenOut[];
|
||||
@ -129,6 +142,9 @@ export interface PrivateUser {
|
||||
loginAttemps?: number;
|
||||
lockedAt?: string;
|
||||
}
|
||||
export interface OIDCRequest {
|
||||
id_token: string;
|
||||
}
|
||||
export interface PasswordResetToken {
|
||||
token: string;
|
||||
}
|
||||
@ -163,9 +179,17 @@ export interface UpdateGroup {
|
||||
id: string;
|
||||
slug: string;
|
||||
categories?: CategoryBase[];
|
||||
webhooks?: unknown[];
|
||||
webhooks?: CreateWebhook[];
|
||||
}
|
||||
export interface CreateWebhook {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
url?: string;
|
||||
webhookType?: WebhookType & string;
|
||||
scheduledTime: string;
|
||||
}
|
||||
export interface UserBase {
|
||||
id?: string;
|
||||
username?: string;
|
||||
fullName?: string;
|
||||
email: string;
|
||||
@ -173,65 +197,12 @@ export interface UserBase {
|
||||
admin?: boolean;
|
||||
group?: string;
|
||||
advanced?: boolean;
|
||||
favoriteRecipes?: string[];
|
||||
canInvite?: boolean;
|
||||
canManage?: boolean;
|
||||
canOrganize?: boolean;
|
||||
}
|
||||
export interface UserFavorites {
|
||||
username?: string;
|
||||
fullName?: string;
|
||||
email: string;
|
||||
authMethod?: AuthMethod & string;
|
||||
admin?: boolean;
|
||||
group?: string;
|
||||
advanced?: boolean;
|
||||
favoriteRecipes?: RecipeSummary[];
|
||||
canInvite?: boolean;
|
||||
canManage?: boolean;
|
||||
canOrganize?: boolean;
|
||||
}
|
||||
export interface RecipeSummary {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
groupId?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
image?: unknown;
|
||||
recipeYield?: string;
|
||||
totalTime?: string;
|
||||
prepTime?: string;
|
||||
cookTime?: string;
|
||||
performTime?: string;
|
||||
description?: string;
|
||||
recipeCategory?: RecipeCategory[];
|
||||
tags?: RecipeTag[];
|
||||
tools?: RecipeTool[];
|
||||
rating?: number;
|
||||
orgURL?: string;
|
||||
dateAdded?: string;
|
||||
dateUpdated?: string;
|
||||
createdAt?: string;
|
||||
updateAt?: string;
|
||||
lastMade?: string;
|
||||
}
|
||||
export interface RecipeCategory {
|
||||
id?: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
export interface RecipeTag {
|
||||
id?: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
export interface RecipeTool {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
onHand?: boolean;
|
||||
}
|
||||
export interface UserIn {
|
||||
id?: string;
|
||||
username?: string;
|
||||
fullName?: string;
|
||||
email: string;
|
||||
@ -239,15 +210,32 @@ export interface UserIn {
|
||||
admin?: boolean;
|
||||
group?: string;
|
||||
advanced?: boolean;
|
||||
favoriteRecipes?: string[];
|
||||
canInvite?: boolean;
|
||||
canManage?: boolean;
|
||||
canOrganize?: boolean;
|
||||
password: string;
|
||||
}
|
||||
export interface UserRatingCreate {
|
||||
recipeId: string;
|
||||
rating?: number;
|
||||
isFavorite?: boolean;
|
||||
userId: string;
|
||||
}
|
||||
export interface UserRatingOut {
|
||||
recipeId: string;
|
||||
rating?: number;
|
||||
isFavorite?: boolean;
|
||||
userId: string;
|
||||
id: string;
|
||||
}
|
||||
export interface UserRatingSummary {
|
||||
recipeId: string;
|
||||
rating?: number;
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
export interface UserSummary {
|
||||
id: string;
|
||||
fullName?: string;
|
||||
fullName: string;
|
||||
}
|
||||
export interface ValidateResetToken {
|
||||
token: string;
|
||||
|
@ -9,17 +9,27 @@ import {
|
||||
LongLiveTokenOut,
|
||||
ResetPassword,
|
||||
UserBase,
|
||||
UserFavorites,
|
||||
UserIn,
|
||||
UserOut,
|
||||
UserRatingOut,
|
||||
UserRatingSummary,
|
||||
UserSummary,
|
||||
} from "~/lib/api/types/user";
|
||||
|
||||
export interface UserRatingsSummaries {
|
||||
ratings: UserRatingSummary[];
|
||||
}
|
||||
|
||||
export interface UserRatingsOut {
|
||||
ratings: UserRatingOut[];
|
||||
}
|
||||
|
||||
const prefix = "/api";
|
||||
|
||||
const routes = {
|
||||
groupUsers: `${prefix}/users/group-users`,
|
||||
usersSelf: `${prefix}/users/self`,
|
||||
ratingsSelf: `${prefix}/users/self/ratings`,
|
||||
groupsSelf: `${prefix}/users/self/group`,
|
||||
passwordReset: `${prefix}/users/reset-password`,
|
||||
passwordChange: `${prefix}/users/password`,
|
||||
@ -30,6 +40,10 @@ const routes = {
|
||||
usersId: (id: string) => `${prefix}/users/${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`,
|
||||
usersIdRatingsSlug: (id: string, slug: string) => `${prefix}/users/${id}/ratings/${slug}`,
|
||||
usersSelfFavoritesId: (id: string) => `${prefix}/users/self/favorites/${id}`,
|
||||
usersSelfRatingsId: (id: string) => `${prefix}/users/self/ratings/${id}`,
|
||||
|
||||
usersApiTokens: `${prefix}/users/api-tokens`,
|
||||
usersApiTokensTokenId: (token_id: string | number) => `${prefix}/users/api-tokens/${token_id}`,
|
||||
@ -56,7 +70,23 @@ export class UserApi extends BaseCRUDAPI<UserIn, UserOut, UserBase> {
|
||||
}
|
||||
|
||||
async getFavorites(id: string) {
|
||||
return await this.requests.get<UserFavorites>(routes.usersIdFavorites(id));
|
||||
return await this.requests.get<UserRatingsOut>(routes.usersIdFavorites(id));
|
||||
}
|
||||
|
||||
async getSelfFavorites() {
|
||||
return await this.requests.get<UserRatingsSummaries>(routes.ratingsSelf);
|
||||
}
|
||||
|
||||
async getRatings(id: string) {
|
||||
return await this.requests.get<UserRatingsOut>(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 getSelfRatings() {
|
||||
return await this.requests.get<UserRatingsSummaries>(routes.ratingsSelf);
|
||||
}
|
||||
|
||||
async changePassword(changePassword: ChangePassword) {
|
||||
|
@ -96,7 +96,7 @@
|
||||
import { defineComponent, reactive, ref, useContext } from "@nuxtjs/composition-api";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { useCategoryStore, useCategoryData } from "~/composables/store";
|
||||
import { RecipeCategory } from "~/lib/api/types/admin";
|
||||
import { RecipeCategory } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
|
@ -1,7 +1,11 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<RecipeCardSection v-if="user && isOwnGroup" :icon="$globals.icons.heart" :title="$tc('user.user-favorites')" :recipes="user.favoriteRecipes">
|
||||
</RecipeCardSection>
|
||||
<RecipeCardSection
|
||||
v-if="recipes && isOwnGroup"
|
||||
:icon="$globals.icons.heart"
|
||||
:title="$tc('user.user-favorites')"
|
||||
:recipes="recipes"
|
||||
/>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
@ -21,14 +25,13 @@ export default defineComponent({
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const userId = route.value.params.id;
|
||||
|
||||
const user = useAsync(async () => {
|
||||
const { data } = await api.users.getFavorites(userId);
|
||||
return data;
|
||||
const recipes = useAsync(async () => {
|
||||
const { data } = await api.recipes.getAll(1, -1, { queryFilter: `favoritedBy.id = "${userId}"` });
|
||||
return data?.items || null;
|
||||
}, useAsyncKey());
|
||||
|
||||
return {
|
||||
user,
|
||||
recipes,
|
||||
isOwnGroup,
|
||||
};
|
||||
},
|
||||
|
@ -7,12 +7,14 @@ from pydantic import ConfigDict
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.orderinglist import ordering_list
|
||||
from sqlalchemy.orm import Mapped, mapped_column, validates
|
||||
from sqlalchemy.orm.attributes import get_history
|
||||
from sqlalchemy.orm.session import object_session
|
||||
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from ..users.user_to_favorite import users_to_favorites
|
||||
from ..users.user_to_recipe import UserToRecipe
|
||||
from .api_extras import ApiExtras, api_extras
|
||||
from .assets import RecipeAsset
|
||||
from .category import recipes_to_categories
|
||||
@ -49,12 +51,20 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
user_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("users.id", use_alter=True), index=True)
|
||||
user: Mapped["User"] = orm.relationship("User", uselist=False, foreign_keys=[user_id])
|
||||
|
||||
meal_entries: Mapped[list["GroupMealPlan"]] = orm.relationship(
|
||||
"GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan"
|
||||
rating: Mapped[float | None] = mapped_column(sa.Float, index=True, nullable=True)
|
||||
rated_by: Mapped[list["User"]] = orm.relationship(
|
||||
"User", secondary=UserToRecipe.__tablename__, back_populates="rated_recipes"
|
||||
)
|
||||
favorited_by: Mapped[list["User"]] = orm.relationship(
|
||||
"User",
|
||||
secondary=UserToRecipe.__tablename__,
|
||||
primaryjoin="and_(RecipeModel.id==UserToRecipe.recipe_id, UserToRecipe.is_favorite==True)",
|
||||
back_populates="favorite_recipes",
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
favorited_by: Mapped[list["User"]] = orm.relationship(
|
||||
"User", secondary=users_to_favorites, back_populates="favorite_recipes"
|
||||
meal_entries: Mapped[list["GroupMealPlan"]] = orm.relationship(
|
||||
"GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# General Recipe Properties
|
||||
@ -110,7 +120,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
)
|
||||
tags: Mapped[list["Tag"]] = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes")
|
||||
notes: Mapped[list[Note]] = orm.relationship("Note", cascade="all, delete-orphan")
|
||||
rating: Mapped[int | None] = mapped_column(sa.Integer)
|
||||
org_url: Mapped[str | None] = mapped_column(sa.String)
|
||||
extras: Mapped[list[ApiExtras]] = orm.relationship("ApiExtras", cascade="all, delete-orphan")
|
||||
is_ocr_recipe: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
@ -246,3 +255,23 @@ def receive_description(target: RecipeModel, value: str, oldvalue, initiator):
|
||||
target.description_normalized = RecipeModel.normalize(value)
|
||||
else:
|
||||
target.description_normalized = None
|
||||
|
||||
|
||||
@event.listens_for(RecipeModel, "before_update")
|
||||
def calculate_rating(mapper, connection, target: RecipeModel):
|
||||
session = object_session(target)
|
||||
if not session:
|
||||
return
|
||||
|
||||
if session.is_modified(target, "rating"):
|
||||
history = get_history(target, "rating")
|
||||
old_value = history.deleted[0] if history.deleted else None
|
||||
new_value = history.added[0] if history.added else None
|
||||
if old_value == new_value:
|
||||
return
|
||||
|
||||
target.rating = (
|
||||
session.query(sa.func.avg(UserToRecipe.rating))
|
||||
.filter(UserToRecipe.recipe_id == target.id, UserToRecipe.rating is not None, UserToRecipe.rating > 0)
|
||||
.scalar()
|
||||
)
|
||||
|
@ -1,3 +1,3 @@
|
||||
from .password_reset import *
|
||||
from .user_to_favorite import *
|
||||
from .user_to_recipe import *
|
||||
from .users import *
|
||||
|
@ -1,12 +0,0 @@
|
||||
from sqlalchemy import Column, ForeignKey, Table, UniqueConstraint
|
||||
|
||||
from .._model_base import SqlAlchemyBase
|
||||
from .._model_utils import GUID
|
||||
|
||||
users_to_favorites = Table(
|
||||
"users_to_favorites",
|
||||
SqlAlchemyBase.metadata,
|
||||
Column("user_id", GUID, ForeignKey("users.id"), index=True),
|
||||
Column("recipe_id", GUID, ForeignKey("recipes.id"), index=True),
|
||||
UniqueConstraint("user_id", "recipe_id", name="user_id_recipe_id_key"),
|
||||
)
|
42
mealie/db/models/users/user_to_recipe.py
Normal file
42
mealie/db/models/users/user_to_recipe.py
Normal file
@ -0,0 +1,42 @@
|
||||
from sqlalchemy import Boolean, Column, Float, ForeignKey, UniqueConstraint, event
|
||||
from sqlalchemy.engine.base import Connection
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
|
||||
|
||||
class UserToRecipe(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "users_to_recipes"
|
||||
__table_args__ = (UniqueConstraint("user_id", "recipe_id", name="user_id_recipe_id_rating_key"),)
|
||||
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
|
||||
user_id = Column(GUID, ForeignKey("users.id"), index=True, primary_key=True)
|
||||
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True, primary_key=True)
|
||||
rating = Column(Float, index=True, nullable=True)
|
||||
is_favorite = Column(Boolean, index=True, nullable=False)
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def update_recipe_rating(session: Session, target: UserToRecipe):
|
||||
from mealie.db.models.recipe.recipe import RecipeModel
|
||||
|
||||
recipe = session.query(RecipeModel).filter(RecipeModel.id == target.recipe_id).first()
|
||||
if not recipe:
|
||||
return
|
||||
|
||||
recipe.rating = -1 # this will trigger the recipe to re-calculate the rating
|
||||
|
||||
|
||||
@event.listens_for(UserToRecipe, "after_insert")
|
||||
@event.listens_for(UserToRecipe, "after_update")
|
||||
@event.listens_for(UserToRecipe, "after_delete")
|
||||
def update_recipe_rating_on_insert_or_delete(_, connection: Connection, target: UserToRecipe):
|
||||
session = Session(bind=connection)
|
||||
|
||||
update_recipe_rating(session, target)
|
||||
session.commit()
|
@ -12,7 +12,7 @@ from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from .user_to_favorite import users_to_favorites
|
||||
from .user_to_recipe import UserToRecipe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..group import Group
|
||||
@ -49,7 +49,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
username: Mapped[str | None] = mapped_column(String, index=True, unique=True)
|
||||
email: Mapped[str | None] = mapped_column(String, unique=True, index=True)
|
||||
password: Mapped[str | None] = mapped_column(String)
|
||||
auth_method: Mapped[Enum(AuthMethod)] = mapped_column(Enum(AuthMethod), default=AuthMethod.MEALIE)
|
||||
auth_method: Mapped[Enum[AuthMethod]] = mapped_column(Enum(AuthMethod), default=AuthMethod.MEALIE)
|
||||
admin: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||
advanced: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||
|
||||
@ -84,8 +84,15 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
"GroupMealPlan", order_by="GroupMealPlan.date", **sp_args
|
||||
)
|
||||
shopping_lists: Mapped[Optional["ShoppingList"]] = orm.relationship("ShoppingList", **sp_args)
|
||||
rated_recipes: Mapped[list["RecipeModel"]] = orm.relationship(
|
||||
"RecipeModel", secondary=UserToRecipe.__tablename__, back_populates="rated_by"
|
||||
)
|
||||
favorite_recipes: Mapped[list["RecipeModel"]] = orm.relationship(
|
||||
"RecipeModel", secondary=users_to_favorites, back_populates="favorited_by"
|
||||
"RecipeModel",
|
||||
secondary=UserToRecipe.__tablename__,
|
||||
primaryjoin="and_(User.id==UserToRecipe.user_id, UserToRecipe.is_favorite==True)",
|
||||
back_populates="favorited_by",
|
||||
viewonly=True,
|
||||
)
|
||||
model_config = ConfigDict(
|
||||
exclude={
|
||||
@ -112,7 +119,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
self.group = Group.get_by_name(session, group)
|
||||
|
||||
self.favorite_recipes = []
|
||||
self.rated_recipes = []
|
||||
|
||||
self.password = password
|
||||
|
||||
|
@ -31,6 +31,7 @@ from mealie.db.models.recipe.tool import Tool
|
||||
from mealie.db.models.server.task import ServerTaskModel
|
||||
from mealie.db.models.users import LongLiveToken, User
|
||||
from mealie.db.models.users.password_reset import PasswordResetModel
|
||||
from mealie.db.models.users.user_to_recipe import UserToRecipe
|
||||
from mealie.repos.repository_foods import RepositoryFood
|
||||
from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
|
||||
from mealie.repos.repository_units import RepositoryUnit
|
||||
@ -58,6 +59,7 @@ from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventOut
|
||||
from mealie.schema.reports.reports import ReportEntryOut, ReportOut
|
||||
from mealie.schema.server import ServerTask
|
||||
from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser
|
||||
from mealie.schema.user.user import UserRatingOut
|
||||
from mealie.schema.user.user_passwords import PrivatePasswordResetToken
|
||||
|
||||
from .repository_generic import RepositoryGeneric
|
||||
@ -65,7 +67,7 @@ from .repository_group import RepositoryGroup
|
||||
from .repository_meals import RepositoryMeals
|
||||
from .repository_recipes import RepositoryRecipes
|
||||
from .repository_shopping_list import RepositoryShoppingList
|
||||
from .repository_users import RepositoryUsers
|
||||
from .repository_users import RepositoryUserRatings, RepositoryUsers
|
||||
|
||||
PK_ID = "id"
|
||||
PK_SLUG = "slug"
|
||||
@ -143,6 +145,10 @@ class AllRepositories:
|
||||
def users(self) -> RepositoryUsers:
|
||||
return RepositoryUsers(self.session, PK_ID, User, PrivateUser)
|
||||
|
||||
@cached_property
|
||||
def user_ratings(self) -> RepositoryUserRatings:
|
||||
return RepositoryUserRatings(self.session, PK_ID, UserToRecipe, UserRatingOut)
|
||||
|
||||
@cached_property
|
||||
def api_tokens(self) -> RepositoryGeneric[LongLiveTokenInDB, LongLiveToken]:
|
||||
return RepositoryGeneric(self.session, PK_ID, LongLiveToken, LongLiveTokenInDB)
|
||||
|
@ -8,6 +8,7 @@ from typing import Any, Generic, TypeVar
|
||||
from fastapi import HTTPException
|
||||
from pydantic import UUID4, BaseModel
|
||||
from sqlalchemy import Select, case, delete, func, nulls_first, nulls_last, select
|
||||
from sqlalchemy.orm import InstrumentedAttribute
|
||||
from sqlalchemy.orm.session import Session
|
||||
from sqlalchemy.sql import sqltypes
|
||||
|
||||
@ -67,9 +68,6 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||
def _filter_builder(self, **kwargs) -> dict[str, Any]:
|
||||
dct = {}
|
||||
|
||||
if self.user_id:
|
||||
dct["user_id"] = self.user_id
|
||||
|
||||
if self.group_id:
|
||||
dct["group_id"] = self.group_id
|
||||
|
||||
@ -287,7 +285,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||
pagination is a method to interact with the filtered database table and return a paginated result
|
||||
using the PaginationBase that provides several data points that are needed to manage pagination
|
||||
on the client side. This method does utilize the _filter_build method to ensure that the results
|
||||
are filtered by the user and group id when applicable.
|
||||
are filtered by the group id when applicable.
|
||||
|
||||
NOTE: When you provide an override you'll need to manually type the result of this method
|
||||
as the override, as the type system is not able to infer the result of this method.
|
||||
@ -368,6 +366,29 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||
query = self.add_order_by_to_query(query, pagination)
|
||||
return query.limit(pagination.per_page).offset((pagination.page - 1) * pagination.per_page), count, total_pages
|
||||
|
||||
def add_order_attr_to_query(
|
||||
self,
|
||||
query: Select,
|
||||
order_attr: InstrumentedAttribute,
|
||||
order_dir: OrderDirection,
|
||||
order_by_null: OrderByNullPosition | None,
|
||||
) -> Select:
|
||||
if order_dir is OrderDirection.asc:
|
||||
order_attr = order_attr.asc()
|
||||
elif order_dir is OrderDirection.desc:
|
||||
order_attr = order_attr.desc()
|
||||
|
||||
# queries handle uppercase and lowercase differently, which is undesirable
|
||||
if isinstance(order_attr.type, sqltypes.String):
|
||||
order_attr = func.lower(order_attr)
|
||||
|
||||
if order_by_null is OrderByNullPosition.first:
|
||||
order_attr = nulls_first(order_attr)
|
||||
elif order_by_null is OrderByNullPosition.last:
|
||||
order_attr = nulls_last(order_attr)
|
||||
|
||||
return query.order_by(order_attr)
|
||||
|
||||
def add_order_by_to_query(self, query: Select, pagination: PaginationQuery) -> Select:
|
||||
if not pagination.order_by:
|
||||
return query
|
||||
@ -399,21 +420,9 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||
order_by, self.model, query=query
|
||||
)
|
||||
|
||||
if order_dir is OrderDirection.asc:
|
||||
order_attr = order_attr.asc()
|
||||
elif order_dir is OrderDirection.desc:
|
||||
order_attr = order_attr.desc()
|
||||
|
||||
# queries handle uppercase and lowercase differently, which is undesirable
|
||||
if isinstance(order_attr.type, sqltypes.String):
|
||||
order_attr = func.lower(order_attr)
|
||||
|
||||
if pagination.order_by_null_position is OrderByNullPosition.first:
|
||||
order_attr = nulls_first(order_attr)
|
||||
elif pagination.order_by_null_position is OrderByNullPosition.last:
|
||||
order_attr = nulls_last(order_attr)
|
||||
|
||||
query = query.order_by(order_attr)
|
||||
query = self.add_order_attr_to_query(
|
||||
query, order_attr, order_dir, pagination.order_by_null_position
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
|
@ -3,11 +3,11 @@ from collections.abc import Sequence
|
||||
from random import randint
|
||||
from uuid import UUID
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pydantic import UUID4
|
||||
from slugify import slugify
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm import InstrumentedAttribute, joinedload
|
||||
|
||||
from mealie.db.models.recipe.category import Category
|
||||
from mealie.db.models.recipe.ingredient import RecipeIngredientModel
|
||||
@ -15,11 +15,12 @@ from mealie.db.models.recipe.recipe import RecipeModel
|
||||
from mealie.db.models.recipe.settings import RecipeSettings
|
||||
from mealie.db.models.recipe.tag import Tag
|
||||
from mealie.db.models.recipe.tool import Tool
|
||||
from mealie.db.models.users.user_to_recipe import UserToRecipe
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||
from mealie.schema.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary, RecipeTag, RecipeTool
|
||||
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
|
||||
from mealie.schema.response.pagination import PaginationQuery
|
||||
from mealie.schema.response.pagination import OrderByNullPosition, OrderDirection, PaginationQuery
|
||||
|
||||
from ..db.models._model_base import SqlAlchemyBase
|
||||
from ..schema._mealie.mealie_model import extract_uuids
|
||||
@ -51,7 +52,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
if order_by:
|
||||
order_attr = getattr(self.model, str(order_by))
|
||||
stmt = (
|
||||
select(self.model)
|
||||
sa.select(self.model)
|
||||
.join(RecipeSettings)
|
||||
.filter(RecipeSettings.public == True) # noqa: E712
|
||||
.order_by(order_attr.desc())
|
||||
@ -61,7 +62,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
return [eff_schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
|
||||
|
||||
stmt = (
|
||||
select(self.model)
|
||||
sa.select(self.model)
|
||||
.join(RecipeSettings)
|
||||
.filter(RecipeSettings.public == True) # noqa: E712
|
||||
.offset(start)
|
||||
@ -121,7 +122,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
order_attr = order_attr.asc()
|
||||
|
||||
stmt = (
|
||||
select(RecipeModel)
|
||||
sa.select(RecipeModel)
|
||||
.options(*args)
|
||||
.filter(RecipeModel.group_id == group_id)
|
||||
.order_by(order_attr)
|
||||
@ -145,9 +146,54 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
ids.append(i_as_uuid)
|
||||
except ValueError:
|
||||
slugs.append(i)
|
||||
additional_ids = self.session.execute(select(model.id).filter(model.slug.in_(slugs))).scalars().all()
|
||||
additional_ids = self.session.execute(sa.select(model.id).filter(model.slug.in_(slugs))).scalars().all()
|
||||
return ids + additional_ids
|
||||
|
||||
def add_order_attr_to_query(
|
||||
self,
|
||||
query: sa.Select,
|
||||
order_attr: InstrumentedAttribute,
|
||||
order_dir: OrderDirection,
|
||||
order_by_null: OrderByNullPosition | None,
|
||||
) -> sa.Select:
|
||||
"""Special handling for ordering recipes by rating"""
|
||||
column_name = order_attr.key
|
||||
if column_name != "rating" or not self.user_id:
|
||||
return super().add_order_attr_to_query(query, order_attr, order_dir, order_by_null)
|
||||
|
||||
# calculate the effictive rating for the user by using the user's rating if it exists,
|
||||
# falling back to the recipe's rating if it doesn't
|
||||
effective_rating_column_name = "_effective_rating"
|
||||
query = query.add_columns(
|
||||
sa.case(
|
||||
(
|
||||
sa.exists().where(
|
||||
UserToRecipe.recipe_id == self.model.id,
|
||||
UserToRecipe.user_id == self.user_id,
|
||||
UserToRecipe.rating is not None,
|
||||
UserToRecipe.rating > 0,
|
||||
),
|
||||
sa.select(UserToRecipe.rating)
|
||||
.where(UserToRecipe.recipe_id == self.model.id, UserToRecipe.user_id == self.user_id)
|
||||
.scalar_subquery(),
|
||||
),
|
||||
else_=self.model.rating,
|
||||
).label(effective_rating_column_name)
|
||||
)
|
||||
|
||||
order_attr = effective_rating_column_name
|
||||
if order_dir is OrderDirection.asc:
|
||||
order_attr = sa.asc(order_attr)
|
||||
elif order_dir is OrderDirection.desc:
|
||||
order_attr = sa.desc(order_attr)
|
||||
|
||||
if order_by_null is OrderByNullPosition.first:
|
||||
order_attr = sa.nulls_first(order_attr)
|
||||
elif order_by_null is OrderByNullPosition.last:
|
||||
order_attr = sa.nulls_last(order_attr)
|
||||
|
||||
return query.order_by(order_attr)
|
||||
|
||||
def page_all( # type: ignore
|
||||
self,
|
||||
pagination: PaginationQuery,
|
||||
@ -165,7 +211,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
) -> RecipePagination:
|
||||
# Copy this, because calling methods (e.g. tests) might rely on it not getting mutated
|
||||
pagination_result = pagination.model_copy()
|
||||
q = select(self.model)
|
||||
q = sa.select(self.model)
|
||||
|
||||
args = [
|
||||
joinedload(RecipeModel.recipe_category),
|
||||
@ -236,7 +282,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
|
||||
ids = [x.id for x in categories]
|
||||
stmt = (
|
||||
select(RecipeModel)
|
||||
sa.select(RecipeModel)
|
||||
.join(RecipeModel.recipe_category)
|
||||
.filter(RecipeModel.recipe_category.any(Category.id.in_(ids)))
|
||||
)
|
||||
@ -301,7 +347,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
require_all_tags=require_all_tags,
|
||||
require_all_tools=require_all_tools,
|
||||
)
|
||||
stmt = select(RecipeModel).filter(*fltr)
|
||||
stmt = sa.select(RecipeModel).filter(*fltr)
|
||||
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
|
||||
|
||||
def get_random_by_categories_and_tags(
|
||||
@ -318,26 +364,29 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
|
||||
filters = self._build_recipe_filter(extract_uuids(categories), extract_uuids(tags)) # type: ignore
|
||||
stmt = (
|
||||
select(RecipeModel).filter(and_(*filters)).order_by(func.random()).limit(1) # Postgres and SQLite specific
|
||||
sa.select(RecipeModel)
|
||||
.filter(sa.and_(*filters))
|
||||
.order_by(sa.func.random())
|
||||
.limit(1) # Postgres and SQLite specific
|
||||
)
|
||||
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
|
||||
|
||||
def get_random(self, limit=1) -> list[Recipe]:
|
||||
stmt = (
|
||||
select(RecipeModel)
|
||||
sa.select(RecipeModel)
|
||||
.filter(RecipeModel.group_id == self.group_id)
|
||||
.order_by(func.random()) # Postgres and SQLite specific
|
||||
.order_by(sa.func.random()) # Postgres and SQLite specific
|
||||
.limit(limit)
|
||||
)
|
||||
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
|
||||
|
||||
def get_by_slug(self, group_id: UUID4, slug: str, limit=1) -> Recipe | None:
|
||||
stmt = select(RecipeModel).filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug)
|
||||
def get_by_slug(self, group_id: UUID4, slug: str) -> Recipe | None:
|
||||
stmt = sa.select(RecipeModel).filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug)
|
||||
dbrecipe = self.session.execute(stmt).scalars().one_or_none()
|
||||
if dbrecipe is None:
|
||||
return None
|
||||
return self.schema.model_validate(dbrecipe)
|
||||
|
||||
def all_ids(self, group_id: UUID4) -> Sequence[UUID4]:
|
||||
stmt = select(RecipeModel.id).filter(RecipeModel.group_id == group_id)
|
||||
stmt = sa.select(RecipeModel.id).filter(RecipeModel.group_id == group_id)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
@ -6,7 +6,8 @@ from sqlalchemy import select
|
||||
|
||||
from mealie.assets import users as users_assets
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.schema.user.user import PrivateUser
|
||||
from mealie.db.models.users.user_to_recipe import UserToRecipe
|
||||
from mealie.schema.user.user import PrivateUser, UserRatingOut
|
||||
|
||||
from ..db.models.users import User
|
||||
from .repository_generic import RepositoryGeneric
|
||||
@ -72,3 +73,26 @@ class RepositoryUsers(RepositoryGeneric[PrivateUser, User]):
|
||||
stmt = select(User).filter(User.locked_at != None) # noqa E711
|
||||
results = self.session.execute(stmt).scalars().all()
|
||||
return [self.schema.model_validate(x) for x in results]
|
||||
|
||||
|
||||
class RepositoryUserRatings(RepositoryGeneric[UserRatingOut, UserToRecipe]):
|
||||
def get_by_user(self, user_id: UUID4, favorites_only=False) -> list[UserRatingOut]:
|
||||
stmt = select(UserToRecipe).filter(UserToRecipe.user_id == user_id)
|
||||
if favorites_only:
|
||||
stmt = stmt.filter(UserToRecipe.is_favorite)
|
||||
|
||||
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]:
|
||||
stmt = select(UserToRecipe).filter(UserToRecipe.recipe_id == recipe_id)
|
||||
if favorites_only:
|
||||
stmt = stmt.filter(UserToRecipe.is_favorite)
|
||||
|
||||
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)
|
||||
|
@ -258,7 +258,8 @@ class RecipeController(BaseRecipeController):
|
||||
if cookbook_data is None:
|
||||
raise HTTPException(status_code=404, detail="cookbook not found")
|
||||
|
||||
pagination_response = self.repo.page_all(
|
||||
# we use the repo by user so we can sort favorites correctly
|
||||
pagination_response = self.repo.by_user(self.user.id).page_all(
|
||||
pagination=q,
|
||||
cookbook=cookbook_data,
|
||||
categories=categories,
|
||||
|
@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import api_tokens, crud, favorites, forgot_password, images, registration
|
||||
from . import api_tokens, crud, forgot_password, images, ratings, registration
|
||||
|
||||
# Must be used because of the way FastAPI works with nested routes
|
||||
user_prefix = "/users"
|
||||
@ -13,4 +13,4 @@ router.include_router(crud.admin_router)
|
||||
router.include_router(forgot_password.router, prefix=user_prefix, tags=["Users: Passwords"])
|
||||
router.include_router(images.router, prefix=user_prefix, tags=["Users: Images"])
|
||||
router.include_router(api_tokens.router)
|
||||
router.include_router(favorites.router, prefix=user_prefix, tags=["Users: Favorites"])
|
||||
router.include_router(ratings.router, prefix=user_prefix, tags=["Users: Ratings"])
|
||||
|
@ -11,7 +11,14 @@ from mealie.routes.users._helpers import assert_user_change_allowed
|
||||
from mealie.schema.response import ErrorResponse, SuccessResponse
|
||||
from mealie.schema.response.pagination import PaginationQuery
|
||||
from mealie.schema.user import ChangePassword, UserBase, UserIn, UserOut
|
||||
from mealie.schema.user.user import GroupInDB, UserPagination, UserSummary, UserSummaryPagination
|
||||
from mealie.schema.user.user import (
|
||||
GroupInDB,
|
||||
UserPagination,
|
||||
UserRatings,
|
||||
UserRatingSummary,
|
||||
UserSummary,
|
||||
UserSummaryPagination,
|
||||
)
|
||||
|
||||
user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"])
|
||||
admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"])
|
||||
@ -74,6 +81,25 @@ class UserController(BaseUserController):
|
||||
def get_logged_in_user(self):
|
||||
return self.user
|
||||
|
||||
@user_router.get("/self/ratings", response_model=UserRatings[UserRatingSummary])
|
||||
def get_logged_in_user_ratings(self):
|
||||
return UserRatings(ratings=self.repos.user_ratings.get_by_user(self.user.id))
|
||||
|
||||
@user_router.get("/self/ratings/{recipe_id}", response_model=UserRatingSummary)
|
||||
def get_logged_in_user_rating_for_recipe(self, recipe_id: UUID4):
|
||||
user_rating = self.repos.user_ratings.get_by_user_and_recipe(self.user.id, recipe_id)
|
||||
if user_rating:
|
||||
return user_rating
|
||||
else:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
ErrorResponse.respond("User has not rated this recipe"),
|
||||
)
|
||||
|
||||
@user_router.get("/self/favorites", response_model=UserRatings[UserRatingSummary])
|
||||
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/group", response_model=GroupInDB)
|
||||
def get_logged_in_user_group(self):
|
||||
return self.group
|
||||
|
@ -1,39 +0,0 @@
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.routes._base import BaseUserController, controller
|
||||
from mealie.routes._base.routers import UserAPIRouter
|
||||
from mealie.routes.users._helpers import assert_user_change_allowed
|
||||
from mealie.schema.user import UserFavorites
|
||||
|
||||
router = UserAPIRouter()
|
||||
|
||||
|
||||
@controller(router)
|
||||
class UserFavoritesController(BaseUserController):
|
||||
@router.get("/{id}/favorites", response_model=UserFavorites)
|
||||
async def get_favorites(self, id: UUID4):
|
||||
"""Get user's favorite recipes"""
|
||||
return self.repos.users.get_one(id, override_schema=UserFavorites)
|
||||
|
||||
@router.post("/{id}/favorites/{slug}")
|
||||
def add_favorite(self, id: UUID4, slug: str):
|
||||
"""Adds a Recipe to the users favorites"""
|
||||
assert_user_change_allowed(id, self.user)
|
||||
|
||||
if not self.user.favorite_recipes:
|
||||
self.user.favorite_recipes = []
|
||||
|
||||
self.user.favorite_recipes.append(slug)
|
||||
self.repos.users.update(self.user.id, self.user)
|
||||
|
||||
@router.delete("/{id}/favorites/{slug}")
|
||||
def remove_favorite(self, id: UUID4, slug: str):
|
||||
"""Adds a Recipe to the users favorites"""
|
||||
assert_user_change_allowed(id, self.user)
|
||||
|
||||
if not self.user.favorite_recipes:
|
||||
self.user.favorite_recipes = []
|
||||
|
||||
self.user.favorite_recipes = [x for x in self.user.favorite_recipes if x != slug]
|
||||
self.repos.users.update(self.user.id, self.user)
|
||||
return
|
81
mealie/routes/users/ratings.py
Normal file
81
mealie/routes/users/ratings.py
Normal file
@ -0,0 +1,81 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.routes._base import BaseUserController, controller
|
||||
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
|
||||
|
||||
router = UserAPIRouter()
|
||||
|
||||
|
||||
@controller(router)
|
||||
class UserRatingsController(BaseUserController):
|
||||
def get_recipe_or_404(self, slug_or_id: str | UUID):
|
||||
"""Fetches a recipe by slug or id, or raises a 404 error if not found."""
|
||||
if isinstance(slug_or_id, str):
|
||||
try:
|
||||
slug_or_id = UUID(slug_or_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
recipes_repo = self.repos.recipes.by_group(self.group_id)
|
||||
if isinstance(slug_or_id, UUID):
|
||||
recipe = recipes_repo.get_one(slug_or_id, key="id")
|
||||
else:
|
||||
recipe = recipes_repo.get_one(slug_or_id, key="slug")
|
||||
|
||||
if not recipe:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
detail=ErrorResponse.respond(message="Not found."),
|
||||
)
|
||||
|
||||
return recipe
|
||||
|
||||
@router.get("/{id}/ratings", response_model=UserRatings[UserRatingOut])
|
||||
async def get_ratings(self, id: UUID4):
|
||||
"""Get user's rated recipes"""
|
||||
return UserRatings(ratings=self.repos.user_ratings.get_by_user(id))
|
||||
|
||||
@router.get("/{id}/favorites", response_model=UserRatings[UserRatingOut])
|
||||
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.post("/{id}/ratings/{slug}")
|
||||
def set_rating(self, id: UUID4, slug: str, data: UserRatingUpdate):
|
||||
"""Sets the user's rating for a recipe"""
|
||||
assert_user_change_allowed(id, self.user)
|
||||
|
||||
recipe = self.get_recipe_or_404(slug)
|
||||
user_rating = self.repos.user_ratings.get_by_user_and_recipe(id, recipe.id)
|
||||
if not user_rating:
|
||||
self.repos.user_ratings.create(
|
||||
UserRatingCreate(
|
||||
user_id=id,
|
||||
recipe_id=recipe.id,
|
||||
rating=data.rating,
|
||||
is_favorite=data.is_favorite or False,
|
||||
)
|
||||
)
|
||||
else:
|
||||
if data.rating is not None:
|
||||
user_rating.rating = data.rating
|
||||
if data.is_favorite is not None:
|
||||
user_rating.is_favorite = data.is_favorite
|
||||
|
||||
self.repos.user_ratings.update(user_rating.id, user_rating)
|
||||
|
||||
@router.post("/{id}/favorites/{slug}")
|
||||
def add_favorite(self, id: UUID4, slug: str):
|
||||
"""Adds a recipe to the user's favorites"""
|
||||
self.set_rating(id, slug, data=UserRatingUpdate(is_favorite=True))
|
||||
|
||||
@router.delete("/{id}/favorites/{slug}")
|
||||
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))
|
@ -1,8 +1,13 @@
|
||||
# This file is auto-generated by gen_schema_exports.py
|
||||
from .datetime_parse import DateError, DateTimeError, DurationError, TimeError
|
||||
from .mealie_model import HasUUID, MealieModel, SearchType
|
||||
|
||||
__all__ = [
|
||||
"HasUUID",
|
||||
"MealieModel",
|
||||
"SearchType",
|
||||
"DateError",
|
||||
"DateTimeError",
|
||||
"DurationError",
|
||||
"TimeError",
|
||||
]
|
||||
|
@ -17,26 +17,9 @@ from .restore import (
|
||||
from .settings import CustomPageBase, CustomPageOut
|
||||
|
||||
__all__ = [
|
||||
"AllBackups",
|
||||
"BackupFile",
|
||||
"BackupOptions",
|
||||
"CreateBackup",
|
||||
"ImportJob",
|
||||
"EmailReady",
|
||||
"EmailSuccess",
|
||||
"EmailTest",
|
||||
"CustomPageBase",
|
||||
"CustomPageOut",
|
||||
"MaintenanceLogs",
|
||||
"MaintenanceStorageDetails",
|
||||
"MaintenanceSummary",
|
||||
"AdminAboutInfo",
|
||||
"AppInfo",
|
||||
"AppStartupInfo",
|
||||
"AppStatistics",
|
||||
"AppTheme",
|
||||
"CheckAppConfig",
|
||||
"OIDCInfo",
|
||||
"CommentImport",
|
||||
"CustomPageImport",
|
||||
"GroupImport",
|
||||
@ -45,8 +28,25 @@ __all__ = [
|
||||
"RecipeImport",
|
||||
"SettingsImport",
|
||||
"UserImport",
|
||||
"EmailReady",
|
||||
"EmailSuccess",
|
||||
"EmailTest",
|
||||
"CustomPageBase",
|
||||
"CustomPageOut",
|
||||
"AdminAboutInfo",
|
||||
"AppInfo",
|
||||
"AppStartupInfo",
|
||||
"AppStatistics",
|
||||
"AppTheme",
|
||||
"CheckAppConfig",
|
||||
"OIDCInfo",
|
||||
"ChowdownURL",
|
||||
"MigrationFile",
|
||||
"MigrationImport",
|
||||
"Migrations",
|
||||
"AllBackups",
|
||||
"BackupFile",
|
||||
"BackupOptions",
|
||||
"CreateBackup",
|
||||
"ImportJob",
|
||||
]
|
||||
|
@ -45,36 +45,6 @@ from .invite_token import CreateInviteToken, EmailInitationResponse, EmailInvita
|
||||
from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType
|
||||
|
||||
__all__ = [
|
||||
"CreateWebhook",
|
||||
"ReadWebhook",
|
||||
"SaveWebhook",
|
||||
"WebhookPagination",
|
||||
"WebhookType",
|
||||
"GroupDataExport",
|
||||
"GroupEventNotifierCreate",
|
||||
"GroupEventNotifierOptions",
|
||||
"GroupEventNotifierOptionsOut",
|
||||
"GroupEventNotifierOptionsSave",
|
||||
"GroupEventNotifierOut",
|
||||
"GroupEventNotifierPrivate",
|
||||
"GroupEventNotifierSave",
|
||||
"GroupEventNotifierUpdate",
|
||||
"GroupEventPagination",
|
||||
"CreateGroupPreferences",
|
||||
"ReadGroupPreferences",
|
||||
"UpdateGroupPreferences",
|
||||
"GroupStatistics",
|
||||
"GroupStorage",
|
||||
"GroupAdminUpdate",
|
||||
"DataMigrationCreate",
|
||||
"SupportedMigrations",
|
||||
"SeederConfig",
|
||||
"SetPermissions",
|
||||
"CreateInviteToken",
|
||||
"EmailInitationResponse",
|
||||
"EmailInvitation",
|
||||
"ReadInviteToken",
|
||||
"SaveInviteToken",
|
||||
"ShoppingListAddRecipeParams",
|
||||
"ShoppingListCreate",
|
||||
"ShoppingListItemBase",
|
||||
@ -97,4 +67,34 @@ __all__ = [
|
||||
"ShoppingListSave",
|
||||
"ShoppingListSummary",
|
||||
"ShoppingListUpdate",
|
||||
"CreateWebhook",
|
||||
"ReadWebhook",
|
||||
"SaveWebhook",
|
||||
"WebhookPagination",
|
||||
"WebhookType",
|
||||
"GroupAdminUpdate",
|
||||
"CreateGroupPreferences",
|
||||
"ReadGroupPreferences",
|
||||
"UpdateGroupPreferences",
|
||||
"SetPermissions",
|
||||
"DataMigrationCreate",
|
||||
"SupportedMigrations",
|
||||
"SeederConfig",
|
||||
"GroupDataExport",
|
||||
"CreateInviteToken",
|
||||
"EmailInitationResponse",
|
||||
"EmailInvitation",
|
||||
"ReadInviteToken",
|
||||
"SaveInviteToken",
|
||||
"GroupStatistics",
|
||||
"GroupStorage",
|
||||
"GroupEventNotifierCreate",
|
||||
"GroupEventNotifierOptions",
|
||||
"GroupEventNotifierOptionsOut",
|
||||
"GroupEventNotifierOptionsSave",
|
||||
"GroupEventNotifierOut",
|
||||
"GroupEventNotifierPrivate",
|
||||
"GroupEventNotifierSave",
|
||||
"GroupEventNotifierUpdate",
|
||||
"GroupEventPagination",
|
||||
]
|
||||
|
@ -30,6 +30,9 @@ __all__ = [
|
||||
"PlanRulesSave",
|
||||
"PlanRulesType",
|
||||
"Tag",
|
||||
"ListItem",
|
||||
"ShoppingListIn",
|
||||
"ShoppingListOut",
|
||||
"CreatePlanEntry",
|
||||
"CreateRandomEntry",
|
||||
"PlanEntryPagination",
|
||||
@ -37,9 +40,6 @@ __all__ = [
|
||||
"ReadPlanEntry",
|
||||
"SavePlanEntry",
|
||||
"UpdatePlanEntry",
|
||||
"ListItem",
|
||||
"ShoppingListIn",
|
||||
"ShoppingListOut",
|
||||
"MealDayIn",
|
||||
"MealDayOut",
|
||||
"MealIn",
|
||||
|
@ -88,8 +88,20 @@ from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, Re
|
||||
from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse
|
||||
|
||||
__all__ = [
|
||||
"Nutrition",
|
||||
"RecipeSettings",
|
||||
"RecipeToolCreate",
|
||||
"RecipeToolOut",
|
||||
"RecipeToolResponse",
|
||||
"RecipeToolSave",
|
||||
"CategoryBase",
|
||||
"CategoryIn",
|
||||
"CategoryOut",
|
||||
"CategorySave",
|
||||
"RecipeCategoryResponse",
|
||||
"RecipeTagResponse",
|
||||
"TagBase",
|
||||
"TagIn",
|
||||
"TagOut",
|
||||
"TagSave",
|
||||
"AssignCategories",
|
||||
"AssignSettings",
|
||||
"AssignTags",
|
||||
@ -97,12 +109,34 @@ __all__ = [
|
||||
"ExportBase",
|
||||
"ExportRecipes",
|
||||
"ExportTypes",
|
||||
"RecipeNote",
|
||||
"RecipeDuplicate",
|
||||
"RecipeSlug",
|
||||
"RecipeZipTokenResponse",
|
||||
"SlugResponse",
|
||||
"UpdateImageResponse",
|
||||
"RecipeShareToken",
|
||||
"RecipeShareTokenCreate",
|
||||
"RecipeShareTokenSave",
|
||||
"RecipeShareTokenSummary",
|
||||
"ScrapeRecipe",
|
||||
"ScrapeRecipeTest",
|
||||
"RecipeCommentCreate",
|
||||
"RecipeCommentOut",
|
||||
"RecipeCommentPagination",
|
||||
"RecipeCommentSave",
|
||||
"RecipeCommentUpdate",
|
||||
"UserBase",
|
||||
"RecipeImageTypes",
|
||||
"CreateRecipe",
|
||||
"CreateRecipeBulk",
|
||||
"CreateRecipeByUrlBulk",
|
||||
"Recipe",
|
||||
"RecipeCategory",
|
||||
"RecipeCategoryPagination",
|
||||
"RecipeLastMade",
|
||||
"RecipePagination",
|
||||
"RecipeSummary",
|
||||
"RecipeTag",
|
||||
"RecipeTagPagination",
|
||||
"RecipeTool",
|
||||
"RecipeToolPagination",
|
||||
"IngredientReferences",
|
||||
"RecipeStep",
|
||||
"CreateIngredientFood",
|
||||
"CreateIngredientFoodAlias",
|
||||
"CreateIngredientUnit",
|
||||
@ -125,16 +159,7 @@ __all__ = [
|
||||
"SaveIngredientFood",
|
||||
"SaveIngredientUnit",
|
||||
"UnitFoodBase",
|
||||
"ScrapeRecipe",
|
||||
"ScrapeRecipeTest",
|
||||
"RecipeImageTypes",
|
||||
"IngredientReferences",
|
||||
"RecipeStep",
|
||||
"RecipeAsset",
|
||||
"RecipeToolCreate",
|
||||
"RecipeToolOut",
|
||||
"RecipeToolResponse",
|
||||
"RecipeToolSave",
|
||||
"RecipeTimelineEventCreate",
|
||||
"RecipeTimelineEventIn",
|
||||
"RecipeTimelineEventOut",
|
||||
@ -142,37 +167,12 @@ __all__ = [
|
||||
"RecipeTimelineEventUpdate",
|
||||
"TimelineEventImage",
|
||||
"TimelineEventType",
|
||||
"CreateRecipe",
|
||||
"CreateRecipeBulk",
|
||||
"CreateRecipeByUrlBulk",
|
||||
"Recipe",
|
||||
"RecipeCategory",
|
||||
"RecipeCategoryPagination",
|
||||
"RecipeLastMade",
|
||||
"RecipePagination",
|
||||
"RecipeSummary",
|
||||
"RecipeTag",
|
||||
"RecipeTagPagination",
|
||||
"RecipeTool",
|
||||
"RecipeToolPagination",
|
||||
"CategoryBase",
|
||||
"CategoryIn",
|
||||
"CategoryOut",
|
||||
"CategorySave",
|
||||
"RecipeCategoryResponse",
|
||||
"RecipeTagResponse",
|
||||
"TagBase",
|
||||
"TagIn",
|
||||
"TagOut",
|
||||
"TagSave",
|
||||
"RecipeCommentCreate",
|
||||
"RecipeCommentOut",
|
||||
"RecipeCommentPagination",
|
||||
"RecipeCommentSave",
|
||||
"RecipeCommentUpdate",
|
||||
"UserBase",
|
||||
"RecipeShareToken",
|
||||
"RecipeShareTokenCreate",
|
||||
"RecipeShareTokenSave",
|
||||
"RecipeShareTokenSummary",
|
||||
"RecipeDuplicate",
|
||||
"RecipeSlug",
|
||||
"RecipeZipTokenResponse",
|
||||
"SlugResponse",
|
||||
"UpdateImageResponse",
|
||||
"Nutrition",
|
||||
"RecipeSettings",
|
||||
"RecipeNote",
|
||||
]
|
||||
|
@ -99,7 +99,7 @@ class RecipeSummary(MealieModel):
|
||||
recipe_category: Annotated[list[RecipeCategory] | None, Field(validate_default=True)] | None = []
|
||||
tags: Annotated[list[RecipeTag] | None, Field(validate_default=True)] = []
|
||||
tools: list[RecipeTool] = []
|
||||
rating: int | None = None
|
||||
rating: float | None = None
|
||||
org_url: str | None = Field(None, alias="orgURL")
|
||||
|
||||
date_added: datetime.date | None = None
|
||||
|
@ -11,6 +11,8 @@ __all__ = [
|
||||
"QueryFilterComponent",
|
||||
"RelationalKeyword",
|
||||
"RelationalOperator",
|
||||
"SearchFilter",
|
||||
"ValidationResponse",
|
||||
"OrderByNullPosition",
|
||||
"OrderDirection",
|
||||
"PaginationBase",
|
||||
@ -19,6 +21,4 @@ __all__ = [
|
||||
"ErrorResponse",
|
||||
"FileTokenResponse",
|
||||
"SuccessResponse",
|
||||
"ValidationResponse",
|
||||
"SearchFilter",
|
||||
]
|
||||
|
@ -14,11 +14,15 @@ from .user import (
|
||||
PrivateUser,
|
||||
UpdateGroup,
|
||||
UserBase,
|
||||
UserFavorites,
|
||||
UserIn,
|
||||
UserOut,
|
||||
UserPagination,
|
||||
UserRatingCreate,
|
||||
UserRatingOut,
|
||||
UserRatings,
|
||||
UserRatingSummary,
|
||||
UserSummary,
|
||||
UserSummaryPagination,
|
||||
)
|
||||
from .user_passwords import (
|
||||
ForgotPassword,
|
||||
@ -55,9 +59,13 @@ __all__ = [
|
||||
"PrivateUser",
|
||||
"UpdateGroup",
|
||||
"UserBase",
|
||||
"UserFavorites",
|
||||
"UserIn",
|
||||
"UserOut",
|
||||
"UserPagination",
|
||||
"UserRatingCreate",
|
||||
"UserRatingOut",
|
||||
"UserRatingSummary",
|
||||
"UserRatings",
|
||||
"UserSummary",
|
||||
"UserSummaryPagination",
|
||||
]
|
||||
|
@ -1,9 +1,9 @@
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
from typing import Annotated, Any, Generic, TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import UUID4, ConfigDict, Field, StringConstraints, field_validator
|
||||
from pydantic import UUID4, BaseModel, ConfigDict, Field, StringConstraints, field_validator
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
from sqlalchemy.orm.interfaces import LoaderOption
|
||||
|
||||
@ -13,13 +13,12 @@ from mealie.db.models.users.users import AuthMethod
|
||||
from mealie.schema._mealie import MealieModel
|
||||
from mealie.schema.group.group_preferences import ReadGroupPreferences
|
||||
from mealie.schema.group.webhook import CreateWebhook, ReadWebhook
|
||||
from mealie.schema.recipe import RecipeSummary
|
||||
from mealie.schema.response.pagination import PaginationBase
|
||||
|
||||
from ...db.models.group import Group
|
||||
from ...db.models.recipe import RecipeModel
|
||||
from ..recipe import CategoryBase
|
||||
|
||||
DataT = TypeVar("DataT", bound=BaseModel)
|
||||
DEFAULT_INTEGRATION_ID = "generic"
|
||||
settings = get_app_settings()
|
||||
|
||||
@ -58,6 +57,38 @@ class GroupBase(MealieModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class UserRatingSummary(MealieModel):
|
||||
recipe_id: UUID4
|
||||
rating: float | None = None
|
||||
is_favorite: Annotated[bool, Field(validate_default=True)] = False
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@field_validator("is_favorite", mode="before")
|
||||
def convert_is_favorite(cls, v: Any) -> bool:
|
||||
if v is None:
|
||||
return False
|
||||
else:
|
||||
return v
|
||||
|
||||
|
||||
class UserRatingCreate(UserRatingSummary):
|
||||
user_id: UUID4
|
||||
|
||||
|
||||
class UserRatingUpdate(MealieModel):
|
||||
rating: float | None = None
|
||||
is_favorite: bool | None = None
|
||||
|
||||
|
||||
class UserRatingOut(UserRatingCreate):
|
||||
id: UUID4
|
||||
|
||||
|
||||
class UserRatings(BaseModel, Generic[DataT]):
|
||||
ratings: list[DataT]
|
||||
|
||||
|
||||
class UserBase(MealieModel):
|
||||
id: UUID4 | None = None
|
||||
username: str | None = None
|
||||
@ -67,7 +98,6 @@ class UserBase(MealieModel):
|
||||
admin: bool = False
|
||||
group: str | None = None
|
||||
advanced: bool = False
|
||||
favorite_recipes: list[str] | None = []
|
||||
|
||||
can_invite: bool = False
|
||||
can_manage: bool = False
|
||||
@ -107,7 +137,6 @@ class UserOut(UserBase):
|
||||
group_slug: str
|
||||
tokens: list[LongLiveTokenOut] | None = None
|
||||
cache_key: str
|
||||
favorite_recipes: Annotated[list[str], Field(validate_default=True)] = []
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@property
|
||||
@ -116,27 +145,7 @@ class UserOut(UserBase):
|
||||
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [joinedload(User.group), joinedload(User.favorite_recipes), joinedload(User.tokens)]
|
||||
|
||||
@field_validator("favorite_recipes", mode="before")
|
||||
def convert_favorite_recipes_to_slugs(cls, v: Any):
|
||||
if not v:
|
||||
return []
|
||||
if not isinstance(v, list):
|
||||
return v
|
||||
|
||||
slugs: list[str] = []
|
||||
for recipe in v:
|
||||
if isinstance(recipe, str):
|
||||
slugs.append(recipe)
|
||||
else:
|
||||
try:
|
||||
slugs.append(recipe.slug)
|
||||
except AttributeError:
|
||||
# this isn't a list of recipes, so we quit early and let Pydantic's typical validation handle it
|
||||
return v
|
||||
|
||||
return slugs
|
||||
return [joinedload(User.group), joinedload(User.tokens)]
|
||||
|
||||
|
||||
class UserSummary(MealieModel):
|
||||
@ -153,20 +162,6 @@ class UserSummaryPagination(PaginationBase):
|
||||
items: list[UserSummary]
|
||||
|
||||
|
||||
class UserFavorites(UserBase):
|
||||
favorite_recipes: list[RecipeSummary] = [] # type: ignore
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [
|
||||
joinedload(User.group),
|
||||
selectinload(User.favorite_recipes).joinedload(RecipeModel.recipe_category),
|
||||
selectinload(User.favorite_recipes).joinedload(RecipeModel.tags),
|
||||
selectinload(User.favorite_recipes).joinedload(RecipeModel.tools),
|
||||
]
|
||||
|
||||
|
||||
class PrivateUser(UserOut):
|
||||
password: str
|
||||
group_id: UUID4
|
||||
@ -198,7 +193,7 @@ class PrivateUser(UserOut):
|
||||
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [joinedload(User.group), selectinload(User.favorite_recipes), joinedload(User.tokens)]
|
||||
return [joinedload(User.group), joinedload(User.tokens)]
|
||||
|
||||
|
||||
class UpdateGroup(GroupBase):
|
||||
@ -244,7 +239,6 @@ class GroupInDB(UpdateGroup):
|
||||
joinedload(Group.webhooks),
|
||||
joinedload(Group.preferences),
|
||||
selectinload(Group.users).joinedload(User.group),
|
||||
selectinload(Group.users).joinedload(User.favorite_recipes),
|
||||
selectinload(Group.users).joinedload(User.tokens),
|
||||
]
|
||||
|
||||
|
@ -39,6 +39,5 @@ class PrivatePasswordResetToken(SavePasswordResetToken):
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [
|
||||
selectinload(PasswordResetModel.user).joinedload(User.group),
|
||||
selectinload(PasswordResetModel.user).joinedload(User.favorite_recipes),
|
||||
selectinload(PasswordResetModel.user).joinedload(User.tokens),
|
||||
]
|
||||
|
@ -20,7 +20,7 @@ from mealie.schema.recipe.recipe_settings import RecipeSettings
|
||||
from mealie.schema.recipe.recipe_step import RecipeStep
|
||||
from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType
|
||||
from mealie.schema.recipe.request_helpers import RecipeDuplicate
|
||||
from mealie.schema.user.user import GroupInDB, PrivateUser
|
||||
from mealie.schema.user.user import GroupInDB, PrivateUser, UserRatingCreate
|
||||
from mealie.services._base_service import BaseService
|
||||
from mealie.services.recipe.recipe_data_service import RecipeDataService
|
||||
|
||||
@ -145,8 +145,20 @@ class RecipeService(BaseService):
|
||||
else:
|
||||
data.settings = RecipeSettings()
|
||||
|
||||
rating_input = data.rating
|
||||
new_recipe = self.repos.recipes.create(data)
|
||||
|
||||
# convert rating into user rating
|
||||
if rating_input:
|
||||
self.repos.user_ratings.create(
|
||||
UserRatingCreate(
|
||||
user_id=self.user.id,
|
||||
recipe_id=new_recipe.id,
|
||||
rating=rating_input,
|
||||
is_favorite=False,
|
||||
)
|
||||
)
|
||||
|
||||
# create first timeline entry
|
||||
timeline_event_data = RecipeTimelineEventCreate(
|
||||
user_id=new_recipe.user_id,
|
||||
|
@ -1,64 +0,0 @@
|
||||
from typing import Generator
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from tests.utils import api_routes
|
||||
from tests.utils.factories import random_string
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def ten_slugs(
|
||||
api_client: TestClient, unique_user: TestUser, database: AllRepositories
|
||||
) -> Generator[list[str], None, None]:
|
||||
slugs = []
|
||||
|
||||
for _ in range(10):
|
||||
payload = {"name": random_string(length=20)}
|
||||
response = api_client.post(api_routes.recipes, json=payload, headers=unique_user.token)
|
||||
assert response.status_code == 201
|
||||
|
||||
response_data = response.json()
|
||||
slugs.append(response_data)
|
||||
|
||||
yield slugs
|
||||
|
||||
for slug in slugs:
|
||||
try:
|
||||
database.recipes.delete(slug)
|
||||
except sqlalchemy.exc.NoResultFound:
|
||||
pass
|
||||
|
||||
|
||||
def test_recipe_favorites(api_client: TestClient, unique_user: TestUser, ten_slugs: list[str]):
|
||||
# Check that the user has no favorites
|
||||
response = api_client.get(api_routes.users_id_favorites(unique_user.user_id), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["favoriteRecipes"] == []
|
||||
|
||||
# Add a few recipes to the user's favorites
|
||||
for slug in ten_slugs:
|
||||
response = api_client.post(
|
||||
api_routes.users_id_favorites_slug(unique_user.user_id, slug), headers=unique_user.token
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check that the user has the recipes in their favorites
|
||||
response = api_client.get(api_routes.users_id_favorites(unique_user.user_id), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["favoriteRecipes"]) == 10
|
||||
|
||||
# Remove a few recipes from the user's favorites
|
||||
for slug in ten_slugs[:5]:
|
||||
response = api_client.delete(
|
||||
api_routes.users_id_favorites_slug(unique_user.user_id, slug), headers=unique_user.token
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check that the user has the recipes in their favorites
|
||||
response = api_client.get(api_routes.users_id_favorites(unique_user.user_id), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["favoriteRecipes"]) == 5
|
364
tests/integration_tests/user_recipe_tests/test_recipe_ratings.py
Normal file
364
tests/integration_tests/user_recipe_tests/test_recipe_ratings.py
Normal file
@ -0,0 +1,364 @@
|
||||
import random
|
||||
from typing import Generator
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from mealie.repos.repository_factory import AllRepositories
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.schema.user.user import UserRatingUpdate
|
||||
from tests.utils import api_routes
|
||||
from tests.utils.factories import random_bool, random_int, random_string
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def recipes(database: AllRepositories, user_tuple: tuple[TestUser, TestUser]) -> Generator[list[Recipe], None, None]:
|
||||
unique_user = random.choice(user_tuple)
|
||||
recipes_repo = database.recipes.by_group(UUID(unique_user.group_id))
|
||||
|
||||
recipes: list[Recipe] = []
|
||||
for _ in range(random_int(10, 20)):
|
||||
slug = random_string()
|
||||
recipes.append(
|
||||
recipes_repo.create(
|
||||
Recipe(
|
||||
user_id=unique_user.user_id,
|
||||
group_id=unique_user.group_id,
|
||||
name=slug,
|
||||
slug=slug,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
yield recipes
|
||||
for recipe in recipes:
|
||||
try:
|
||||
recipes_repo.delete(recipe.id, match_key="id")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_self_route", [True, False])
|
||||
def test_user_recipe_favorites(
|
||||
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_favorites(unique_user.user_id), headers=unique_user.token)
|
||||
assert response.json()["ratings"] == []
|
||||
|
||||
recipes_to_favorite = random.sample(recipes, random_int(5, len(recipes)))
|
||||
|
||||
# add favorites
|
||||
for recipe in recipes_to_favorite:
|
||||
response = api_client.post(
|
||||
api_routes.users_id_favorites_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_favorites
|
||||
else:
|
||||
get_url = api_routes.users_id_favorites(unique_user.user_id)
|
||||
|
||||
response = api_client.get(get_url, headers=unique_user.token)
|
||||
ratings = response.json()["ratings"]
|
||||
|
||||
assert len(ratings) == len(recipes_to_favorite)
|
||||
fetched_recipe_ids = set(rating["recipeId"] for rating in ratings)
|
||||
favorited_recipe_ids = set(str(recipe.id) for recipe in recipes_to_favorite)
|
||||
assert fetched_recipe_ids == favorited_recipe_ids
|
||||
|
||||
# remove favorites
|
||||
recipe_favorites_to_remove = random.sample(recipes_to_favorite, 3)
|
||||
for recipe in recipe_favorites_to_remove:
|
||||
response = api_client.delete(
|
||||
api_routes.users_id_favorites_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_favorite) - len(recipe_favorites_to_remove)
|
||||
fetched_recipe_ids = set(rating["recipeId"] for rating in ratings)
|
||||
removed_recipe_ids = set(str(recipe.id) for recipe in recipe_favorites_to_remove)
|
||||
assert fetched_recipe_ids == favorited_recipe_ids - removed_recipe_ids
|
||||
|
||||
|
||||
@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
|
||||
):
|
||||
unique_user = random.choice(user_tuple)
|
||||
if add_favorite:
|
||||
response = api_client.post(
|
||||
api_routes.users_id_favorites_slug(unique_user.user_id, random_string()), headers=unique_user.token
|
||||
)
|
||||
else:
|
||||
response = api_client.delete(
|
||||
api_routes.users_id_favorites_slug(unique_user.user_id, random_string()), headers=unique_user.token
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_self_route", [True, False])
|
||||
def test_set_user_recipe_ratings(
|
||||
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_ratings(unique_user.user_id), headers=unique_user.token)
|
||||
assert response.json()["ratings"] == []
|
||||
|
||||
recipes_to_rate = random.sample(recipes, random_int(8, len(recipes)))
|
||||
|
||||
expected_ratings_by_recipe_id: dict[str, UserRatingUpdate] = {}
|
||||
for recipe in recipes_to_rate:
|
||||
new_rating = UserRatingUpdate(
|
||||
rating=random.uniform(1, 5),
|
||||
)
|
||||
expected_ratings_by_recipe_id[str(recipe.id)] = new_rating
|
||||
response = api_client.post(
|
||||
api_routes.users_id_ratings_slug(unique_user.user_id, recipe.slug),
|
||||
json=new_rating.model_dump(),
|
||||
headers=unique_user.token,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
if use_self_route:
|
||||
get_url = api_routes.users_self_ratings
|
||||
else:
|
||||
get_url = api_routes.users_id_ratings(unique_user.user_id)
|
||||
|
||||
response = api_client.get(get_url, headers=unique_user.token)
|
||||
ratings = response.json()["ratings"]
|
||||
|
||||
assert len(ratings) == len(recipes_to_rate)
|
||||
for rating in ratings:
|
||||
recipe_id = rating["recipeId"]
|
||||
assert rating["rating"] == expected_ratings_by_recipe_id[recipe_id].rating
|
||||
assert not rating["isFavorite"]
|
||||
|
||||
|
||||
def test_set_user_rating_invalid_recipe_404(api_client: TestClient, user_tuple: tuple[TestUser, TestUser]):
|
||||
unique_user = random.choice(user_tuple)
|
||||
rating = UserRatingUpdate(rating=random.uniform(1, 5))
|
||||
response = api_client.post(
|
||||
api_routes.users_id_ratings_slug(unique_user.user_id, random_string()),
|
||||
json=rating.model_dump(),
|
||||
headers=unique_user.token,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_set_rating_and_favorite(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_favorite=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["isFavorite"] 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
|
||||
):
|
||||
initial_rating_value = 1
|
||||
updated_rating_value = 5
|
||||
|
||||
unique_user = random.choice(user_tuple)
|
||||
recipe = random.choice(recipes)
|
||||
rating = UserRatingUpdate(rating=initial_rating_value, is_favorite=favorite_value)
|
||||
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"] == initial_rating_value
|
||||
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
|
||||
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"] == updated_rating_value
|
||||
assert data["isFavorite"] == favorite_value
|
||||
|
||||
|
||||
def test_set_favorite_preserve_rating(
|
||||
api_client: TestClient, user_tuple: tuple[TestUser, TestUser], recipes: list[Recipe]
|
||||
):
|
||||
rating_value = random.uniform(1, 5)
|
||||
initial_favorite_value = random_bool()
|
||||
|
||||
unique_user = random.choice(user_tuple)
|
||||
recipe = random.choice(recipes)
|
||||
rating = UserRatingUpdate(rating=rating_value, is_favorite=initial_favorite_value)
|
||||
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_value
|
||||
assert data["isFavorite"] is initial_favorite_value
|
||||
|
||||
rating.is_favorite = not initial_favorite_value
|
||||
rating.rating = None # this should be ignored and the rating value should be preserved
|
||||
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_value
|
||||
assert data["isFavorite"] is not initial_favorite_value
|
||||
|
||||
|
||||
def test_set_rating_to_zero(api_client: TestClient, user_tuple: tuple[TestUser, TestUser], recipes: list[Recipe]):
|
||||
unique_user = random.choice(user_tuple)
|
||||
recipe = random.choice(recipes)
|
||||
|
||||
rating_value = random.uniform(1, 5)
|
||||
rating = UserRatingUpdate(rating=rating_value)
|
||||
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["rating"] == rating_value
|
||||
|
||||
rating.rating = 0
|
||||
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["rating"] == 0
|
||||
|
||||
|
||||
def test_delete_recipe_deletes_ratings(
|
||||
database: AllRepositories, 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_favorite=random.choice([True, False, None]))
|
||||
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)
|
||||
assert response.status_code == 200
|
||||
assert response.json()
|
||||
|
||||
database.recipes.delete(recipe.id, match_key="id")
|
||||
response = api_client.get(api_routes.users_self_ratings_recipe_id(recipe.id), headers=unique_user.token)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_recipe_rating_is_average_user_rating(
|
||||
api_client: TestClient, user_tuple: tuple[TestUser, TestUser], recipes: list[Recipe]
|
||||
):
|
||||
recipe = random.choice(recipes)
|
||||
user_ratings = (UserRatingUpdate(rating=5), UserRatingUpdate(rating=2))
|
||||
|
||||
for i, user in enumerate(user_tuple):
|
||||
response = api_client.post(
|
||||
api_routes.users_id_ratings_slug(user.user_id, recipe.slug),
|
||||
json=user_ratings[i].model_dump(),
|
||||
headers=user.token,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = api_client.get(api_routes.recipes_slug(recipe.slug), headers=user_tuple[0].token)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["rating"] == 3.5
|
||||
|
||||
|
||||
def test_recipe_rating_is_readonly(
|
||||
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_favorite=random.choice([True, False, None]))
|
||||
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.recipes_slug(recipe.slug), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["rating"] == rating.rating
|
||||
|
||||
# try to update the rating manually and verify it didn't change
|
||||
new_rating = random.uniform(1, 5)
|
||||
assert new_rating != rating.rating
|
||||
response = api_client.patch(
|
||||
api_routes.recipes_slug(recipe.slug), json={"rating": new_rating}, headers=unique_user.token
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["rating"] == rating.rating
|
||||
|
||||
response = api_client.get(api_routes.recipes_slug(recipe.slug), headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["rating"] == rating.rating
|
@ -1,5 +1,6 @@
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
@ -10,7 +11,7 @@ from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipeSummary
|
||||
from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagSave
|
||||
from mealie.schema.recipe.recipe_tool import RecipeToolSave
|
||||
from mealie.schema.response import OrderDirection, PaginationQuery
|
||||
from mealie.schema.user.user import GroupBase
|
||||
from mealie.schema.user.user import GroupBase, UserRatingCreate
|
||||
from tests.utils.factories import random_email, random_string
|
||||
from tests.utils.fixture_schemas import TestUser
|
||||
|
||||
@ -658,3 +659,126 @@ def test_random_order_recipe_search(
|
||||
pagination.pagination_seed = str(datetime.now())
|
||||
random_ordered.append(repo.page_all(pagination, search="soup").items)
|
||||
assert not all(i == random_ordered[0] for i in random_ordered)
|
||||
|
||||
|
||||
def test_order_by_rating(database: AllRepositories, user_tuple: tuple[TestUser, TestUser]):
|
||||
user_1, user_2 = user_tuple
|
||||
repo = database.recipes.by_group(UUID(user_1.group_id))
|
||||
|
||||
recipes: list[Recipe] = []
|
||||
for i in range(3):
|
||||
slug = f"recipe-{i+1}-{random_string(5)}"
|
||||
recipes.append(
|
||||
database.recipes.create(
|
||||
Recipe(
|
||||
user_id=user_1.user_id,
|
||||
group_id=user_1.group_id,
|
||||
name=slug,
|
||||
slug=slug,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# set the rating for user_1 and confirm both users see the same ordering
|
||||
recipe_1, recipe_2, recipe_3 = recipes
|
||||
database.user_ratings.create(
|
||||
UserRatingCreate(
|
||||
user_id=user_1.user_id,
|
||||
recipe_id=recipe_1.id,
|
||||
rating=5,
|
||||
)
|
||||
)
|
||||
database.user_ratings.create(
|
||||
UserRatingCreate(
|
||||
user_id=user_1.user_id,
|
||||
recipe_id=recipe_2.id,
|
||||
rating=4,
|
||||
)
|
||||
)
|
||||
database.user_ratings.create(
|
||||
UserRatingCreate(
|
||||
user_id=user_1.user_id,
|
||||
recipe_id=recipe_3.id,
|
||||
rating=3,
|
||||
)
|
||||
)
|
||||
|
||||
pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.desc)
|
||||
data_1 = repo.by_user(user_1.user_id).page_all(pq).items
|
||||
data_2 = repo.by_user(user_2.user_id).page_all(pq).items
|
||||
for data in [data_1, data_2]:
|
||||
assert len(data) == 3
|
||||
assert data[0].slug == recipe_1.slug # global and user rating == 5
|
||||
assert data[1].slug == recipe_2.slug # global and user rating == 4
|
||||
assert data[2].slug == recipe_3.slug # global and user rating == 3
|
||||
|
||||
pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.asc)
|
||||
data_1 = repo.by_user(user_1.user_id).page_all(pq).items
|
||||
data_2 = repo.by_user(user_2.user_id).page_all(pq).items
|
||||
for data in [data_1, data_2]:
|
||||
assert len(data) == 3
|
||||
assert data[0].slug == recipe_3.slug # global and user rating == 3
|
||||
assert data[1].slug == recipe_2.slug # global and user rating == 4
|
||||
assert data[2].slug == recipe_1.slug # global and user rating == 5
|
||||
|
||||
# set rating for one recipe for user_2 and confirm user_2 sees the correct order and user_1's order is unchanged
|
||||
database.user_ratings.create(
|
||||
UserRatingCreate(
|
||||
user_id=user_2.user_id,
|
||||
recipe_id=recipe_1.id,
|
||||
rating=3.5,
|
||||
)
|
||||
)
|
||||
|
||||
pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.desc)
|
||||
data_1 = repo.by_user(user_1.user_id).page_all(pq).items
|
||||
data_2 = repo.by_user(user_2.user_id).page_all(pq).items
|
||||
|
||||
assert len(data_1) == 3
|
||||
assert data_1[0].slug == recipe_1.slug # user rating == 5
|
||||
assert data_1[1].slug == recipe_2.slug # user rating == 4
|
||||
assert data_1[2].slug == recipe_3.slug # user rating == 3
|
||||
|
||||
assert len(data_2) == 3
|
||||
assert data_2[0].slug == recipe_2.slug # global rating == 4
|
||||
assert data_2[1].slug == recipe_1.slug # user rating == 3.5
|
||||
assert data_2[2].slug == recipe_3.slug # user rating == 3
|
||||
|
||||
pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.asc)
|
||||
data_1 = repo.by_user(user_1.user_id).page_all(pq).items
|
||||
data_2 = repo.by_user(user_2.user_id).page_all(pq).items
|
||||
|
||||
assert len(data_1) == 3
|
||||
assert data_1[0].slug == recipe_3.slug # global and user rating == 3
|
||||
assert data_1[1].slug == recipe_2.slug # global and user rating == 4
|
||||
assert data_1[2].slug == recipe_1.slug # global and user rating == 5
|
||||
|
||||
assert len(data_2) == 3
|
||||
assert data_2[0].slug == recipe_3.slug # user rating == 3
|
||||
assert data_2[1].slug == recipe_1.slug # user rating == 3.5
|
||||
assert data_2[2].slug == recipe_2.slug # global rating == 4
|
||||
|
||||
# verify public users see only global ratings
|
||||
database.user_ratings.create(
|
||||
UserRatingCreate(
|
||||
user_id=user_2.user_id,
|
||||
recipe_id=recipe_2.id,
|
||||
rating=1,
|
||||
)
|
||||
)
|
||||
|
||||
pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.desc)
|
||||
data = database.recipes.by_group(UUID(user_1.group_id)).page_all(pq).items
|
||||
|
||||
assert len(data) == 3
|
||||
assert data[0].slug == recipe_1.slug # global rating == 4.25 (avg of 5 and 3.5)
|
||||
assert data[1].slug == recipe_3.slug # global rating == 3
|
||||
assert data[2].slug == recipe_2.slug # global rating == 2.5 (avg of 4 and 1)
|
||||
|
||||
pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.asc)
|
||||
data = database.recipes.by_group(UUID(user_1.group_id)).page_all(pq).items
|
||||
|
||||
assert len(data) == 3
|
||||
assert data[0].slug == recipe_2.slug # global rating == 2.5 (avg of 4 and 1)
|
||||
assert data[1].slug == recipe_3.slug # global rating == 3
|
||||
assert data[2].slug == recipe_1.slug # global rating == 4.25 (avg of 5 and 3.5)
|
||||
|
@ -1,4 +1,5 @@
|
||||
import filecmp
|
||||
import statistics
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
@ -8,11 +9,14 @@ from sqlalchemy.orm import Session
|
||||
import tests.data as test_data
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.db.db_setup import session_context
|
||||
from mealie.db.models._model_utils import GUID
|
||||
from mealie.db.models.group import Group
|
||||
from mealie.db.models.group.shopping_list import ShoppingList
|
||||
from mealie.db.models.labels import MultiPurposeLabel
|
||||
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
|
||||
from mealie.db.models.recipe.recipe import RecipeModel
|
||||
from mealie.db.models.users.user_to_recipe import UserToRecipe
|
||||
from mealie.db.models.users.users import User
|
||||
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
|
||||
from mealie.services.backups_v2.backup_file import BackupFile
|
||||
from mealie.services.backups_v2.backup_v2 import BackupV2
|
||||
@ -155,5 +159,18 @@ def test_database_restore_data(backup_path: Path):
|
||||
assert unit.name_normalized
|
||||
if unit.abbreviation:
|
||||
assert unit.abbreviation_normalized
|
||||
|
||||
# 2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_ratings
|
||||
users_by_group_id: dict[GUID, list[User]] = {}
|
||||
for recipe in recipes:
|
||||
users = users_by_group_id.get(recipe.group_id)
|
||||
if users is None:
|
||||
users = session.query(User).filter(User.group_id == recipe.group_id).all()
|
||||
users_by_group_id[recipe.group_id] = users
|
||||
|
||||
user_to_recipes = session.query(UserToRecipe).filter(UserToRecipe.recipe_id == recipe.id).all()
|
||||
user_ratings = [x.rating for x in user_to_recipes if x.rating]
|
||||
assert recipe.rating == (statistics.mean(user_ratings) if user_ratings else None)
|
||||
|
||||
finally:
|
||||
backup_v2.restore(original_data_backup)
|
||||
|
@ -181,8 +181,12 @@ users_reset_password = "/api/users/reset-password"
|
||||
"""`/api/users/reset-password`"""
|
||||
users_self = "/api/users/self"
|
||||
"""`/api/users/self`"""
|
||||
users_self_favorites = "/api/users/self/favorites"
|
||||
"""`/api/users/self/favorites`"""
|
||||
users_self_group = "/api/users/self/group"
|
||||
"""`/api/users/self/group`"""
|
||||
users_self_ratings = "/api/users/self/ratings"
|
||||
"""`/api/users/self/ratings`"""
|
||||
utils_download = "/api/utils/download"
|
||||
"""`/api/utils/download`"""
|
||||
validators_group = "/api/validators/group"
|
||||
@ -490,6 +494,21 @@ def users_id_image(id):
|
||||
return f"{prefix}/users/{id}/image"
|
||||
|
||||
|
||||
def users_id_ratings(id):
|
||||
"""`/api/users/{id}/ratings`"""
|
||||
return f"{prefix}/users/{id}/ratings"
|
||||
|
||||
|
||||
def users_id_ratings_slug(id, slug):
|
||||
"""`/api/users/{id}/ratings/{slug}`"""
|
||||
return f"{prefix}/users/{id}/ratings/{slug}"
|
||||
|
||||
|
||||
def users_item_id(item_id):
|
||||
"""`/api/users/{item_id}`"""
|
||||
return f"{prefix}/users/{item_id}"
|
||||
|
||||
|
||||
def users_self_ratings_recipe_id(recipe_id):
|
||||
"""`/api/users/self/ratings/{recipe_id}`"""
|
||||
return f"{prefix}/users/self/ratings/{recipe_id}"
|
||||
|
Loading…
x
Reference in New Issue
Block a user