mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-06-05 06:35:28 -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">
|
<script lang="ts">
|
||||||
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
||||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
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({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<div v-if="!open" class="custom-btn-group ma-1">
|
<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" />
|
<RecipeTimelineBadge v-if="loggedIn" button-style :slug="recipe.slug" :recipe-name="recipe.name" />
|
||||||
<div v-if="loggedIn">
|
<div v-if="loggedIn">
|
||||||
<v-tooltip v-if="!locked" bottom color="info">
|
<v-tooltip v-if="!locked" bottom color="info">
|
||||||
|
@ -35,9 +35,9 @@
|
|||||||
|
|
||||||
<slot name="actions">
|
<slot name="actions">
|
||||||
<v-card-actions v-if="showRecipeContent" class="px-1">
|
<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>
|
<v-spacer></v-spacer>
|
||||||
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" />
|
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" />
|
||||||
|
|
||||||
@ -97,6 +97,10 @@ export default defineComponent({
|
|||||||
required: false,
|
required: false,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
|
ratingColor: {
|
||||||
|
type: String,
|
||||||
|
default: "secondary",
|
||||||
|
},
|
||||||
image: {
|
image: {
|
||||||
type: String,
|
type: String,
|
||||||
required: false,
|
required: false,
|
||||||
|
@ -38,17 +38,14 @@
|
|||||||
</v-list-item-subtitle>
|
</v-list-item-subtitle>
|
||||||
<div class="d-flex flex-wrap justify-end align-center">
|
<div class="d-flex flex-wrap justify-end align-center">
|
||||||
<slot name="actions">
|
<slot name="actions">
|
||||||
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :slug="slug" show-always />
|
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :recipe-id="recipeId" show-always />
|
||||||
<v-rating
|
<RecipeRating
|
||||||
v-if="showRecipeContent"
|
|
||||||
color="secondary"
|
|
||||||
:class="isOwnGroup ? 'ml-auto' : 'ml-auto pb-2'"
|
:class="isOwnGroup ? 'ml-auto' : 'ml-auto pb-2'"
|
||||||
background-color="secondary lighten-3"
|
|
||||||
dense
|
|
||||||
length="5"
|
|
||||||
size="15"
|
|
||||||
:value="rating"
|
:value="rating"
|
||||||
></v-rating>
|
:recipe-id="recipeId"
|
||||||
|
:slug="slug"
|
||||||
|
:small="true"
|
||||||
|
/>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
|
|
||||||
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
<!-- 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 RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||||
import RecipeCardImage from "./RecipeCardImage.vue";
|
import RecipeCardImage from "./RecipeCardImage.vue";
|
||||||
|
import RecipeRating from "./RecipeRating.vue";
|
||||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
RecipeFavoriteBadge,
|
RecipeFavoriteBadge,
|
||||||
RecipeContextMenu,
|
RecipeContextMenu,
|
||||||
|
RecipeRating,
|
||||||
RecipeCardImage,
|
RecipeCardImage,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
|
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";
|
export type UrlPrefixParam = "tags" | "categories" | "tools";
|
||||||
|
|
||||||
|
@ -22,11 +22,12 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||||
|
import { useUserSelfRatings } from "~/composables/use-users";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { UserOut } from "~/lib/api/types/user";
|
import { UserOut } from "~/lib/api/types/user";
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
slug: {
|
recipeId: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
@ -42,19 +43,23 @@ export default defineComponent({
|
|||||||
setup(props) {
|
setup(props) {
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
const { $auth } = useContext();
|
const { $auth } = useContext();
|
||||||
|
const { userRatings, refreshUserRatings } = useUserSelfRatings();
|
||||||
|
|
||||||
// TODO Setup the correct type for $auth.user
|
// TODO Setup the correct type for $auth.user
|
||||||
// See https://github.com/nuxt-community/auth-module/issues/1097
|
// See https://github.com/nuxt-community/auth-module/issues/1097
|
||||||
const user = computed(() => $auth.user as unknown as UserOut);
|
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() {
|
async function toggleFavorite() {
|
||||||
if (!isFavorite.value) {
|
if (!isFavorite.value) {
|
||||||
await api.users.addFavorite(user.value?.id, props.slug);
|
await api.users.addFavorite(user.value?.id, props.recipeId);
|
||||||
} else {
|
} 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 };
|
return { isFavorite, toggleFavorite };
|
||||||
|
@ -46,7 +46,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, ref, useContext, computed, onMounted } from "@nuxtjs/composition-api";
|
import { defineComponent, ref, useContext, computed, onMounted } from "@nuxtjs/composition-api";
|
||||||
import RecipeOrganizerDialog from "./RecipeOrganizerDialog.vue";
|
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 { RecipeTool } from "~/lib/api/types/admin";
|
||||||
import { useTagStore } from "~/composables/store/use-tag-store";
|
import { useTagStore } from "~/composables/store/use-tag-store";
|
||||||
import { useCategoryStore, useToolStore } from "~/composables/store";
|
import { useCategoryStore, useToolStore } from "~/composables/store";
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-card-title class="headline pa-0 flex-column align-center">
|
<v-card-title class="headline pa-0 flex-column align-center">
|
||||||
{{ recipe.name }}
|
{{ 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-card-title>
|
||||||
<v-divider class="my-2"></v-divider>
|
<v-divider class="my-2"></v-divider>
|
||||||
<SafeMarkdown :source="recipe.description" />
|
<SafeMarkdown :source="recipe.description" />
|
||||||
|
@ -20,7 +20,7 @@
|
|||||||
v-if="landscape && $vuetify.breakpoint.smAndUp"
|
v-if="landscape && $vuetify.breakpoint.smAndUp"
|
||||||
:key="recipe.slug"
|
:key="recipe.slug"
|
||||||
v-model="recipe.rating"
|
v-model="recipe.rating"
|
||||||
:name="recipe.name"
|
:recipe-id="recipe.id"
|
||||||
:slug="recipe.slug"
|
:slug="recipe.slug"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -24,7 +24,7 @@
|
|||||||
v-if="$vuetify.breakpoint.smAndDown"
|
v-if="$vuetify.breakpoint.smAndDown"
|
||||||
:key="recipe.slug"
|
:key="recipe.slug"
|
||||||
v-model="recipe.rating"
|
v-model="recipe.rating"
|
||||||
:name="recipe.name"
|
:recipe-id="recipe.id"
|
||||||
:slug="recipe.slug"
|
:slug="recipe.slug"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,34 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<div @click.prevent>
|
<div @click.prevent>
|
||||||
|
<v-hover v-slot="{ hover }">
|
||||||
<v-rating
|
<v-rating
|
||||||
v-model="rating"
|
:value="rating.ratingValue"
|
||||||
|
:half-increments="(!hover) || (!isOwnGroup)"
|
||||||
:readonly="!isOwnGroup"
|
:readonly="!isOwnGroup"
|
||||||
color="secondary"
|
:color="hover ? attrs.hoverColor : attrs.color"
|
||||||
background-color="secondary lighten-3"
|
:background-color="attrs.backgroundColor"
|
||||||
length="5"
|
length="5"
|
||||||
:dense="small ? true : undefined"
|
:dense="small ? true : undefined"
|
||||||
:size="small ? 15 : undefined"
|
:size="small ? 15 : undefined"
|
||||||
hover
|
hover
|
||||||
:value="value"
|
|
||||||
clearable
|
clearable
|
||||||
@input="updateRating"
|
@input="updateRating"
|
||||||
@click="updateRating"
|
@click="updateRating"
|
||||||
></v-rating>
|
/>
|
||||||
|
</v-hover>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<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 { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserSelfRatings } from "~/composables/use-users";
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
emitOnly: {
|
emitOnly: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
// TODO Remove name prop?
|
recipeId: {
|
||||||
name: {
|
|
||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
@ -44,26 +45,79 @@ export default defineComponent({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
preferGroupRating: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
setup(props, context) {
|
setup(props, context) {
|
||||||
|
const { $auth } = useContext();
|
||||||
const { isOwnGroup } = useLoggedInState();
|
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();
|
// prefer user rating over group rating
|
||||||
function updateRating(val: number | null) {
|
const rating = computed<Rating>(() => {
|
||||||
if (val === 0) {
|
if (!ratingsLoaded.value) {
|
||||||
val = null;
|
return { ratingValue: undefined, hasUserRating: undefined };
|
||||||
}
|
}
|
||||||
if (!props.emitOnly) {
|
if (!($auth.user?.id) || props.preferGroupRating) {
|
||||||
api.recipes.patchOne(props.slug, {
|
return { ratingValue: props.value, hasUserRating: false };
|
||||||
rating: val,
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
setRating(props.slug, val || 0, null);
|
||||||
}
|
}
|
||||||
context.emit("input", val);
|
context.emit("input", val);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { isOwnGroup, rating, updateRating };
|
return {
|
||||||
|
attrs,
|
||||||
|
isOwnGroup,
|
||||||
|
rating,
|
||||||
|
updateRating,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@ -2,7 +2,7 @@ import { reactive, ref, useAsync } from "@nuxtjs/composition-api";
|
|||||||
import { useAsyncKey } from "../use-utils";
|
import { useAsyncKey } from "../use-utils";
|
||||||
import { useUserApi } from "~/composables/api";
|
import { useUserApi } from "~/composables/api";
|
||||||
import { VForm } from "~/types/vuetify";
|
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) {
|
export const useTools = function (eager = true) {
|
||||||
const workingToolData = reactive<RecipeTool>({
|
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 { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory";
|
||||||
import { usePublicExploreApi } from "../api/api-client";
|
import { usePublicExploreApi } from "../api/api-client";
|
||||||
import { useUserApi } from "~/composables/api";
|
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 categoryStore: Ref<RecipeCategory[]> = ref([]);
|
||||||
const publicStoreLoading = ref(false);
|
const publicStoreLoading = ref(false);
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
export { useUserForm } from "./user-form";
|
export { useUserForm } from "./user-form";
|
||||||
export { useUserRegistrationForm } from "./user-registration-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
|
/* 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 type AuthMethod = "Mealie" | "LDAP" | "OIDC";
|
||||||
|
|
||||||
export interface ChangePassword {
|
export interface ChangePassword {
|
||||||
currentPassword: string;
|
currentPassword?: string;
|
||||||
newPassword: string;
|
newPassword: string;
|
||||||
}
|
}
|
||||||
export interface CreateToken {
|
export interface CreateToken {
|
||||||
@ -30,6 +31,11 @@ export interface CreateUserRegistration {
|
|||||||
seedData?: boolean;
|
seedData?: boolean;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
}
|
}
|
||||||
|
export interface CredentialsRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
remember_me?: boolean;
|
||||||
|
}
|
||||||
export interface DeleteTokenResponse {
|
export interface DeleteTokenResponse {
|
||||||
tokenDelete: string;
|
tokenDelete: string;
|
||||||
}
|
}
|
||||||
@ -44,7 +50,7 @@ export interface GroupInDB {
|
|||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
categories?: CategoryBase[];
|
categories?: CategoryBase[];
|
||||||
webhooks?: unknown[];
|
webhooks?: ReadWebhook[];
|
||||||
users?: UserOut[];
|
users?: UserOut[];
|
||||||
preferences?: ReadGroupPreferences;
|
preferences?: ReadGroupPreferences;
|
||||||
}
|
}
|
||||||
@ -60,7 +66,17 @@ export interface CategoryBase {
|
|||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
}
|
}
|
||||||
|
export interface ReadWebhook {
|
||||||
|
enabled?: boolean;
|
||||||
|
name?: string;
|
||||||
|
url?: string;
|
||||||
|
webhookType?: WebhookType & string;
|
||||||
|
scheduledTime: string;
|
||||||
|
groupId: string;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
export interface UserOut {
|
export interface UserOut {
|
||||||
|
id: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -68,11 +84,9 @@ export interface UserOut {
|
|||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
group: string;
|
group: string;
|
||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
favoriteRecipes?: string[];
|
|
||||||
canInvite?: boolean;
|
canInvite?: boolean;
|
||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
canOrganize?: boolean;
|
canOrganize?: boolean;
|
||||||
id: string;
|
|
||||||
groupId: string;
|
groupId: string;
|
||||||
groupSlug: string;
|
groupSlug: string;
|
||||||
tokens?: LongLiveTokenOut[];
|
tokens?: LongLiveTokenOut[];
|
||||||
@ -109,6 +123,7 @@ export interface LongLiveTokenInDB {
|
|||||||
user: PrivateUser;
|
user: PrivateUser;
|
||||||
}
|
}
|
||||||
export interface PrivateUser {
|
export interface PrivateUser {
|
||||||
|
id: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -116,11 +131,9 @@ export interface PrivateUser {
|
|||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
group: string;
|
group: string;
|
||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
favoriteRecipes?: string[];
|
|
||||||
canInvite?: boolean;
|
canInvite?: boolean;
|
||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
canOrganize?: boolean;
|
canOrganize?: boolean;
|
||||||
id: string;
|
|
||||||
groupId: string;
|
groupId: string;
|
||||||
groupSlug: string;
|
groupSlug: string;
|
||||||
tokens?: LongLiveTokenOut[];
|
tokens?: LongLiveTokenOut[];
|
||||||
@ -129,6 +142,9 @@ export interface PrivateUser {
|
|||||||
loginAttemps?: number;
|
loginAttemps?: number;
|
||||||
lockedAt?: string;
|
lockedAt?: string;
|
||||||
}
|
}
|
||||||
|
export interface OIDCRequest {
|
||||||
|
id_token: string;
|
||||||
|
}
|
||||||
export interface PasswordResetToken {
|
export interface PasswordResetToken {
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
@ -163,9 +179,17 @@ export interface UpdateGroup {
|
|||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
categories?: CategoryBase[];
|
categories?: CategoryBase[];
|
||||||
webhooks?: unknown[];
|
webhooks?: CreateWebhook[];
|
||||||
|
}
|
||||||
|
export interface CreateWebhook {
|
||||||
|
enabled?: boolean;
|
||||||
|
name?: string;
|
||||||
|
url?: string;
|
||||||
|
webhookType?: WebhookType & string;
|
||||||
|
scheduledTime: string;
|
||||||
}
|
}
|
||||||
export interface UserBase {
|
export interface UserBase {
|
||||||
|
id?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -173,65 +197,12 @@ export interface UserBase {
|
|||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
group?: string;
|
group?: string;
|
||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
favoriteRecipes?: string[];
|
|
||||||
canInvite?: boolean;
|
canInvite?: boolean;
|
||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
canOrganize?: 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 {
|
export interface UserIn {
|
||||||
|
id?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -239,15 +210,32 @@ export interface UserIn {
|
|||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
group?: string;
|
group?: string;
|
||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
favoriteRecipes?: string[];
|
|
||||||
canInvite?: boolean;
|
canInvite?: boolean;
|
||||||
canManage?: boolean;
|
canManage?: boolean;
|
||||||
canOrganize?: boolean;
|
canOrganize?: boolean;
|
||||||
password: string;
|
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 {
|
export interface UserSummary {
|
||||||
id: string;
|
id: string;
|
||||||
fullName?: string;
|
fullName: string;
|
||||||
}
|
}
|
||||||
export interface ValidateResetToken {
|
export interface ValidateResetToken {
|
||||||
token: string;
|
token: string;
|
||||||
|
@ -9,17 +9,27 @@ import {
|
|||||||
LongLiveTokenOut,
|
LongLiveTokenOut,
|
||||||
ResetPassword,
|
ResetPassword,
|
||||||
UserBase,
|
UserBase,
|
||||||
UserFavorites,
|
|
||||||
UserIn,
|
UserIn,
|
||||||
UserOut,
|
UserOut,
|
||||||
|
UserRatingOut,
|
||||||
|
UserRatingSummary,
|
||||||
UserSummary,
|
UserSummary,
|
||||||
} from "~/lib/api/types/user";
|
} from "~/lib/api/types/user";
|
||||||
|
|
||||||
|
export interface UserRatingsSummaries {
|
||||||
|
ratings: UserRatingSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRatingsOut {
|
||||||
|
ratings: UserRatingOut[];
|
||||||
|
}
|
||||||
|
|
||||||
const prefix = "/api";
|
const prefix = "/api";
|
||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
groupUsers: `${prefix}/users/group-users`,
|
groupUsers: `${prefix}/users/group-users`,
|
||||||
usersSelf: `${prefix}/users/self`,
|
usersSelf: `${prefix}/users/self`,
|
||||||
|
ratingsSelf: `${prefix}/users/self/ratings`,
|
||||||
groupsSelf: `${prefix}/users/self/group`,
|
groupsSelf: `${prefix}/users/self/group`,
|
||||||
passwordReset: `${prefix}/users/reset-password`,
|
passwordReset: `${prefix}/users/reset-password`,
|
||||||
passwordChange: `${prefix}/users/password`,
|
passwordChange: `${prefix}/users/password`,
|
||||||
@ -30,6 +40,10 @@ const routes = {
|
|||||||
usersId: (id: string) => `${prefix}/users/${id}`,
|
usersId: (id: string) => `${prefix}/users/${id}`,
|
||||||
usersIdFavorites: (id: string) => `${prefix}/users/${id}/favorites`,
|
usersIdFavorites: (id: string) => `${prefix}/users/${id}/favorites`,
|
||||||
usersIdFavoritesSlug: (id: string, slug: string) => `${prefix}/users/${id}/favorites/${slug}`,
|
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`,
|
usersApiTokens: `${prefix}/users/api-tokens`,
|
||||||
usersApiTokensTokenId: (token_id: string | number) => `${prefix}/users/api-tokens/${token_id}`,
|
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) {
|
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) {
|
async changePassword(changePassword: ChangePassword) {
|
||||||
|
@ -96,7 +96,7 @@
|
|||||||
import { defineComponent, reactive, ref, useContext } from "@nuxtjs/composition-api";
|
import { defineComponent, reactive, ref, useContext } from "@nuxtjs/composition-api";
|
||||||
import { validators } from "~/composables/use-validators";
|
import { validators } from "~/composables/use-validators";
|
||||||
import { useCategoryStore, useCategoryData } from "~/composables/store";
|
import { useCategoryStore, useCategoryData } from "~/composables/store";
|
||||||
import { RecipeCategory } from "~/lib/api/types/admin";
|
import { RecipeCategory } from "~/lib/api/types/recipe";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
setup() {
|
setup() {
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container>
|
<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>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -21,14 +25,13 @@ export default defineComponent({
|
|||||||
const { isOwnGroup } = useLoggedInState();
|
const { isOwnGroup } = useLoggedInState();
|
||||||
|
|
||||||
const userId = route.value.params.id;
|
const userId = route.value.params.id;
|
||||||
|
const recipes = useAsync(async () => {
|
||||||
const user = useAsync(async () => {
|
const { data } = await api.recipes.getAll(1, -1, { queryFilter: `favoritedBy.id = "${userId}"` });
|
||||||
const { data } = await api.users.getFavorites(userId);
|
return data?.items || null;
|
||||||
return data;
|
|
||||||
}, useAsyncKey());
|
}, useAsyncKey());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
recipes,
|
||||||
isOwnGroup,
|
isOwnGroup,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -7,12 +7,14 @@ from pydantic import ConfigDict
|
|||||||
from sqlalchemy import event
|
from sqlalchemy import event
|
||||||
from sqlalchemy.ext.orderinglist import ordering_list
|
from sqlalchemy.ext.orderinglist import ordering_list
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, validates
|
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 mealie.db.models._model_utils.guid import GUID
|
||||||
|
|
||||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||||
from .._model_utils import auto_init
|
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 .api_extras import ApiExtras, api_extras
|
||||||
from .assets import RecipeAsset
|
from .assets import RecipeAsset
|
||||||
from .category import recipes_to_categories
|
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_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])
|
user: Mapped["User"] = orm.relationship("User", uselist=False, foreign_keys=[user_id])
|
||||||
|
|
||||||
meal_entries: Mapped[list["GroupMealPlan"]] = orm.relationship(
|
rating: Mapped[float | None] = mapped_column(sa.Float, index=True, nullable=True)
|
||||||
"GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan"
|
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(
|
meal_entries: Mapped[list["GroupMealPlan"]] = orm.relationship(
|
||||||
"User", secondary=users_to_favorites, back_populates="favorite_recipes"
|
"GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
# General Recipe Properties
|
# 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")
|
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")
|
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)
|
org_url: Mapped[str | None] = mapped_column(sa.String)
|
||||||
extras: Mapped[list[ApiExtras]] = orm.relationship("ApiExtras", cascade="all, delete-orphan")
|
extras: Mapped[list[ApiExtras]] = orm.relationship("ApiExtras", cascade="all, delete-orphan")
|
||||||
is_ocr_recipe: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
|
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)
|
target.description_normalized = RecipeModel.normalize(value)
|
||||||
else:
|
else:
|
||||||
target.description_normalized = None
|
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 .password_reset import *
|
||||||
from .user_to_favorite import *
|
from .user_to_recipe import *
|
||||||
from .users 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_base import BaseMixins, SqlAlchemyBase
|
||||||
from .._model_utils import auto_init
|
from .._model_utils import auto_init
|
||||||
from .user_to_favorite import users_to_favorites
|
from .user_to_recipe import UserToRecipe
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..group import Group
|
from ..group import Group
|
||||||
@ -49,7 +49,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
|||||||
username: Mapped[str | None] = mapped_column(String, index=True, unique=True)
|
username: Mapped[str | None] = mapped_column(String, index=True, unique=True)
|
||||||
email: Mapped[str | None] = mapped_column(String, unique=True, index=True)
|
email: Mapped[str | None] = mapped_column(String, unique=True, index=True)
|
||||||
password: Mapped[str | None] = mapped_column(String)
|
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)
|
admin: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||||
advanced: 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
|
"GroupMealPlan", order_by="GroupMealPlan.date", **sp_args
|
||||||
)
|
)
|
||||||
shopping_lists: Mapped[Optional["ShoppingList"]] = orm.relationship("ShoppingList", **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(
|
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(
|
model_config = ConfigDict(
|
||||||
exclude={
|
exclude={
|
||||||
@ -112,7 +119,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
|||||||
|
|
||||||
self.group = Group.get_by_name(session, group)
|
self.group = Group.get_by_name(session, group)
|
||||||
|
|
||||||
self.favorite_recipes = []
|
self.rated_recipes = []
|
||||||
|
|
||||||
self.password = password
|
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.server.task import ServerTaskModel
|
||||||
from mealie.db.models.users import LongLiveToken, User
|
from mealie.db.models.users import LongLiveToken, User
|
||||||
from mealie.db.models.users.password_reset import PasswordResetModel
|
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_foods import RepositoryFood
|
||||||
from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
|
from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
|
||||||
from mealie.repos.repository_units import RepositoryUnit
|
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.reports.reports import ReportEntryOut, ReportOut
|
||||||
from mealie.schema.server import ServerTask
|
from mealie.schema.server import ServerTask
|
||||||
from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser
|
from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser
|
||||||
|
from mealie.schema.user.user import UserRatingOut
|
||||||
from mealie.schema.user.user_passwords import PrivatePasswordResetToken
|
from mealie.schema.user.user_passwords import PrivatePasswordResetToken
|
||||||
|
|
||||||
from .repository_generic import RepositoryGeneric
|
from .repository_generic import RepositoryGeneric
|
||||||
@ -65,7 +67,7 @@ from .repository_group import RepositoryGroup
|
|||||||
from .repository_meals import RepositoryMeals
|
from .repository_meals import RepositoryMeals
|
||||||
from .repository_recipes import RepositoryRecipes
|
from .repository_recipes import RepositoryRecipes
|
||||||
from .repository_shopping_list import RepositoryShoppingList
|
from .repository_shopping_list import RepositoryShoppingList
|
||||||
from .repository_users import RepositoryUsers
|
from .repository_users import RepositoryUserRatings, RepositoryUsers
|
||||||
|
|
||||||
PK_ID = "id"
|
PK_ID = "id"
|
||||||
PK_SLUG = "slug"
|
PK_SLUG = "slug"
|
||||||
@ -143,6 +145,10 @@ class AllRepositories:
|
|||||||
def users(self) -> RepositoryUsers:
|
def users(self) -> RepositoryUsers:
|
||||||
return RepositoryUsers(self.session, PK_ID, User, PrivateUser)
|
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
|
@cached_property
|
||||||
def api_tokens(self) -> RepositoryGeneric[LongLiveTokenInDB, LongLiveToken]:
|
def api_tokens(self) -> RepositoryGeneric[LongLiveTokenInDB, LongLiveToken]:
|
||||||
return RepositoryGeneric(self.session, PK_ID, LongLiveToken, LongLiveTokenInDB)
|
return RepositoryGeneric(self.session, PK_ID, LongLiveToken, LongLiveTokenInDB)
|
||||||
|
@ -8,6 +8,7 @@ from typing import Any, Generic, TypeVar
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from pydantic import UUID4, BaseModel
|
from pydantic import UUID4, BaseModel
|
||||||
from sqlalchemy import Select, case, delete, func, nulls_first, nulls_last, select
|
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.orm.session import Session
|
||||||
from sqlalchemy.sql import sqltypes
|
from sqlalchemy.sql import sqltypes
|
||||||
|
|
||||||
@ -67,9 +68,6 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
|||||||
def _filter_builder(self, **kwargs) -> dict[str, Any]:
|
def _filter_builder(self, **kwargs) -> dict[str, Any]:
|
||||||
dct = {}
|
dct = {}
|
||||||
|
|
||||||
if self.user_id:
|
|
||||||
dct["user_id"] = self.user_id
|
|
||||||
|
|
||||||
if self.group_id:
|
if self.group_id:
|
||||||
dct["group_id"] = 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
|
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
|
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
|
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
|
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.
|
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)
|
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
|
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:
|
def add_order_by_to_query(self, query: Select, pagination: PaginationQuery) -> Select:
|
||||||
if not pagination.order_by:
|
if not pagination.order_by:
|
||||||
return query
|
return query
|
||||||
@ -399,21 +420,9 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
|||||||
order_by, self.model, query=query
|
order_by, self.model, query=query
|
||||||
)
|
)
|
||||||
|
|
||||||
if order_dir is OrderDirection.asc:
|
query = self.add_order_attr_to_query(
|
||||||
order_attr = order_attr.asc()
|
query, order_attr, order_dir, pagination.order_by_null_position
|
||||||
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)
|
|
||||||
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
@ -3,11 +3,11 @@ from collections.abc import Sequence
|
|||||||
from random import randint
|
from random import randint
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
from pydantic import UUID4
|
from pydantic import UUID4
|
||||||
from slugify import slugify
|
from slugify import slugify
|
||||||
from sqlalchemy import and_, func, select
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
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.category import Category
|
||||||
from mealie.db.models.recipe.ingredient import RecipeIngredientModel
|
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.settings import RecipeSettings
|
||||||
from mealie.db.models.recipe.tag import Tag
|
from mealie.db.models.recipe.tag import Tag
|
||||||
from mealie.db.models.recipe.tool import Tool
|
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.cookbook.cookbook import ReadCookBook
|
||||||
from mealie.schema.recipe import Recipe
|
from mealie.schema.recipe import Recipe
|
||||||
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary, RecipeTag, RecipeTool
|
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary, RecipeTag, RecipeTool
|
||||||
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
|
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 ..db.models._model_base import SqlAlchemyBase
|
||||||
from ..schema._mealie.mealie_model import extract_uuids
|
from ..schema._mealie.mealie_model import extract_uuids
|
||||||
@ -51,7 +52,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
|||||||
if order_by:
|
if order_by:
|
||||||
order_attr = getattr(self.model, str(order_by))
|
order_attr = getattr(self.model, str(order_by))
|
||||||
stmt = (
|
stmt = (
|
||||||
select(self.model)
|
sa.select(self.model)
|
||||||
.join(RecipeSettings)
|
.join(RecipeSettings)
|
||||||
.filter(RecipeSettings.public == True) # noqa: E712
|
.filter(RecipeSettings.public == True) # noqa: E712
|
||||||
.order_by(order_attr.desc())
|
.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()]
|
return [eff_schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(self.model)
|
sa.select(self.model)
|
||||||
.join(RecipeSettings)
|
.join(RecipeSettings)
|
||||||
.filter(RecipeSettings.public == True) # noqa: E712
|
.filter(RecipeSettings.public == True) # noqa: E712
|
||||||
.offset(start)
|
.offset(start)
|
||||||
@ -121,7 +122,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
|||||||
order_attr = order_attr.asc()
|
order_attr = order_attr.asc()
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(RecipeModel)
|
sa.select(RecipeModel)
|
||||||
.options(*args)
|
.options(*args)
|
||||||
.filter(RecipeModel.group_id == group_id)
|
.filter(RecipeModel.group_id == group_id)
|
||||||
.order_by(order_attr)
|
.order_by(order_attr)
|
||||||
@ -145,9 +146,54 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
|||||||
ids.append(i_as_uuid)
|
ids.append(i_as_uuid)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
slugs.append(i)
|
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
|
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
|
def page_all( # type: ignore
|
||||||
self,
|
self,
|
||||||
pagination: PaginationQuery,
|
pagination: PaginationQuery,
|
||||||
@ -165,7 +211,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
|||||||
) -> RecipePagination:
|
) -> RecipePagination:
|
||||||
# Copy this, because calling methods (e.g. tests) might rely on it not getting mutated
|
# Copy this, because calling methods (e.g. tests) might rely on it not getting mutated
|
||||||
pagination_result = pagination.model_copy()
|
pagination_result = pagination.model_copy()
|
||||||
q = select(self.model)
|
q = sa.select(self.model)
|
||||||
|
|
||||||
args = [
|
args = [
|
||||||
joinedload(RecipeModel.recipe_category),
|
joinedload(RecipeModel.recipe_category),
|
||||||
@ -236,7 +282,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
|||||||
|
|
||||||
ids = [x.id for x in categories]
|
ids = [x.id for x in categories]
|
||||||
stmt = (
|
stmt = (
|
||||||
select(RecipeModel)
|
sa.select(RecipeModel)
|
||||||
.join(RecipeModel.recipe_category)
|
.join(RecipeModel.recipe_category)
|
||||||
.filter(RecipeModel.recipe_category.any(Category.id.in_(ids)))
|
.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_tags=require_all_tags,
|
||||||
require_all_tools=require_all_tools,
|
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()]
|
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
|
||||||
|
|
||||||
def get_random_by_categories_and_tags(
|
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
|
filters = self._build_recipe_filter(extract_uuids(categories), extract_uuids(tags)) # type: ignore
|
||||||
stmt = (
|
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()]
|
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
|
||||||
|
|
||||||
def get_random(self, limit=1) -> list[Recipe]:
|
def get_random(self, limit=1) -> list[Recipe]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(RecipeModel)
|
sa.select(RecipeModel)
|
||||||
.filter(RecipeModel.group_id == self.group_id)
|
.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)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
|
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:
|
def get_by_slug(self, group_id: UUID4, slug: str) -> Recipe | None:
|
||||||
stmt = select(RecipeModel).filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug)
|
stmt = sa.select(RecipeModel).filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug)
|
||||||
dbrecipe = self.session.execute(stmt).scalars().one_or_none()
|
dbrecipe = self.session.execute(stmt).scalars().one_or_none()
|
||||||
if dbrecipe is None:
|
if dbrecipe is None:
|
||||||
return None
|
return None
|
||||||
return self.schema.model_validate(dbrecipe)
|
return self.schema.model_validate(dbrecipe)
|
||||||
|
|
||||||
def all_ids(self, group_id: UUID4) -> Sequence[UUID4]:
|
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()
|
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.assets import users as users_assets
|
||||||
from mealie.core.config import get_app_settings
|
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 ..db.models.users import User
|
||||||
from .repository_generic import RepositoryGeneric
|
from .repository_generic import RepositoryGeneric
|
||||||
@ -72,3 +73,26 @@ class RepositoryUsers(RepositoryGeneric[PrivateUser, User]):
|
|||||||
stmt = select(User).filter(User.locked_at != None) # noqa E711
|
stmt = select(User).filter(User.locked_at != None) # noqa E711
|
||||||
results = self.session.execute(stmt).scalars().all()
|
results = self.session.execute(stmt).scalars().all()
|
||||||
return [self.schema.model_validate(x) for x in results]
|
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:
|
if cookbook_data is None:
|
||||||
raise HTTPException(status_code=404, detail="cookbook not found")
|
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,
|
pagination=q,
|
||||||
cookbook=cookbook_data,
|
cookbook=cookbook_data,
|
||||||
categories=categories,
|
categories=categories,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
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
|
# Must be used because of the way FastAPI works with nested routes
|
||||||
user_prefix = "/users"
|
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(forgot_password.router, prefix=user_prefix, tags=["Users: Passwords"])
|
||||||
router.include_router(images.router, prefix=user_prefix, tags=["Users: Images"])
|
router.include_router(images.router, prefix=user_prefix, tags=["Users: Images"])
|
||||||
router.include_router(api_tokens.router)
|
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 import ErrorResponse, SuccessResponse
|
||||||
from mealie.schema.response.pagination import PaginationQuery
|
from mealie.schema.response.pagination import PaginationQuery
|
||||||
from mealie.schema.user import ChangePassword, UserBase, UserIn, UserOut
|
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"])
|
user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"])
|
||||||
admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"])
|
admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"])
|
||||||
@ -74,6 +81,25 @@ class UserController(BaseUserController):
|
|||||||
def get_logged_in_user(self):
|
def get_logged_in_user(self):
|
||||||
return self.user
|
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)
|
@user_router.get("/self/group", response_model=GroupInDB)
|
||||||
def get_logged_in_user_group(self):
|
def get_logged_in_user_group(self):
|
||||||
return self.group
|
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
|
# 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
|
from .mealie_model import HasUUID, MealieModel, SearchType
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"HasUUID",
|
"HasUUID",
|
||||||
"MealieModel",
|
"MealieModel",
|
||||||
"SearchType",
|
"SearchType",
|
||||||
|
"DateError",
|
||||||
|
"DateTimeError",
|
||||||
|
"DurationError",
|
||||||
|
"TimeError",
|
||||||
]
|
]
|
||||||
|
@ -17,26 +17,9 @@ from .restore import (
|
|||||||
from .settings import CustomPageBase, CustomPageOut
|
from .settings import CustomPageBase, CustomPageOut
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AllBackups",
|
|
||||||
"BackupFile",
|
|
||||||
"BackupOptions",
|
|
||||||
"CreateBackup",
|
|
||||||
"ImportJob",
|
|
||||||
"EmailReady",
|
|
||||||
"EmailSuccess",
|
|
||||||
"EmailTest",
|
|
||||||
"CustomPageBase",
|
|
||||||
"CustomPageOut",
|
|
||||||
"MaintenanceLogs",
|
"MaintenanceLogs",
|
||||||
"MaintenanceStorageDetails",
|
"MaintenanceStorageDetails",
|
||||||
"MaintenanceSummary",
|
"MaintenanceSummary",
|
||||||
"AdminAboutInfo",
|
|
||||||
"AppInfo",
|
|
||||||
"AppStartupInfo",
|
|
||||||
"AppStatistics",
|
|
||||||
"AppTheme",
|
|
||||||
"CheckAppConfig",
|
|
||||||
"OIDCInfo",
|
|
||||||
"CommentImport",
|
"CommentImport",
|
||||||
"CustomPageImport",
|
"CustomPageImport",
|
||||||
"GroupImport",
|
"GroupImport",
|
||||||
@ -45,8 +28,25 @@ __all__ = [
|
|||||||
"RecipeImport",
|
"RecipeImport",
|
||||||
"SettingsImport",
|
"SettingsImport",
|
||||||
"UserImport",
|
"UserImport",
|
||||||
|
"EmailReady",
|
||||||
|
"EmailSuccess",
|
||||||
|
"EmailTest",
|
||||||
|
"CustomPageBase",
|
||||||
|
"CustomPageOut",
|
||||||
|
"AdminAboutInfo",
|
||||||
|
"AppInfo",
|
||||||
|
"AppStartupInfo",
|
||||||
|
"AppStatistics",
|
||||||
|
"AppTheme",
|
||||||
|
"CheckAppConfig",
|
||||||
|
"OIDCInfo",
|
||||||
"ChowdownURL",
|
"ChowdownURL",
|
||||||
"MigrationFile",
|
"MigrationFile",
|
||||||
"MigrationImport",
|
"MigrationImport",
|
||||||
"Migrations",
|
"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
|
from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType
|
||||||
|
|
||||||
__all__ = [
|
__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",
|
"ShoppingListAddRecipeParams",
|
||||||
"ShoppingListCreate",
|
"ShoppingListCreate",
|
||||||
"ShoppingListItemBase",
|
"ShoppingListItemBase",
|
||||||
@ -97,4 +67,34 @@ __all__ = [
|
|||||||
"ShoppingListSave",
|
"ShoppingListSave",
|
||||||
"ShoppingListSummary",
|
"ShoppingListSummary",
|
||||||
"ShoppingListUpdate",
|
"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",
|
"PlanRulesSave",
|
||||||
"PlanRulesType",
|
"PlanRulesType",
|
||||||
"Tag",
|
"Tag",
|
||||||
|
"ListItem",
|
||||||
|
"ShoppingListIn",
|
||||||
|
"ShoppingListOut",
|
||||||
"CreatePlanEntry",
|
"CreatePlanEntry",
|
||||||
"CreateRandomEntry",
|
"CreateRandomEntry",
|
||||||
"PlanEntryPagination",
|
"PlanEntryPagination",
|
||||||
@ -37,9 +40,6 @@ __all__ = [
|
|||||||
"ReadPlanEntry",
|
"ReadPlanEntry",
|
||||||
"SavePlanEntry",
|
"SavePlanEntry",
|
||||||
"UpdatePlanEntry",
|
"UpdatePlanEntry",
|
||||||
"ListItem",
|
|
||||||
"ShoppingListIn",
|
|
||||||
"ShoppingListOut",
|
|
||||||
"MealDayIn",
|
"MealDayIn",
|
||||||
"MealDayOut",
|
"MealDayOut",
|
||||||
"MealIn",
|
"MealIn",
|
||||||
|
@ -88,8 +88,20 @@ from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, Re
|
|||||||
from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse
|
from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Nutrition",
|
"RecipeToolCreate",
|
||||||
"RecipeSettings",
|
"RecipeToolOut",
|
||||||
|
"RecipeToolResponse",
|
||||||
|
"RecipeToolSave",
|
||||||
|
"CategoryBase",
|
||||||
|
"CategoryIn",
|
||||||
|
"CategoryOut",
|
||||||
|
"CategorySave",
|
||||||
|
"RecipeCategoryResponse",
|
||||||
|
"RecipeTagResponse",
|
||||||
|
"TagBase",
|
||||||
|
"TagIn",
|
||||||
|
"TagOut",
|
||||||
|
"TagSave",
|
||||||
"AssignCategories",
|
"AssignCategories",
|
||||||
"AssignSettings",
|
"AssignSettings",
|
||||||
"AssignTags",
|
"AssignTags",
|
||||||
@ -97,12 +109,34 @@ __all__ = [
|
|||||||
"ExportBase",
|
"ExportBase",
|
||||||
"ExportRecipes",
|
"ExportRecipes",
|
||||||
"ExportTypes",
|
"ExportTypes",
|
||||||
"RecipeNote",
|
"RecipeShareToken",
|
||||||
"RecipeDuplicate",
|
"RecipeShareTokenCreate",
|
||||||
"RecipeSlug",
|
"RecipeShareTokenSave",
|
||||||
"RecipeZipTokenResponse",
|
"RecipeShareTokenSummary",
|
||||||
"SlugResponse",
|
"ScrapeRecipe",
|
||||||
"UpdateImageResponse",
|
"ScrapeRecipeTest",
|
||||||
|
"RecipeCommentCreate",
|
||||||
|
"RecipeCommentOut",
|
||||||
|
"RecipeCommentPagination",
|
||||||
|
"RecipeCommentSave",
|
||||||
|
"RecipeCommentUpdate",
|
||||||
|
"UserBase",
|
||||||
|
"RecipeImageTypes",
|
||||||
|
"CreateRecipe",
|
||||||
|
"CreateRecipeBulk",
|
||||||
|
"CreateRecipeByUrlBulk",
|
||||||
|
"Recipe",
|
||||||
|
"RecipeCategory",
|
||||||
|
"RecipeCategoryPagination",
|
||||||
|
"RecipeLastMade",
|
||||||
|
"RecipePagination",
|
||||||
|
"RecipeSummary",
|
||||||
|
"RecipeTag",
|
||||||
|
"RecipeTagPagination",
|
||||||
|
"RecipeTool",
|
||||||
|
"RecipeToolPagination",
|
||||||
|
"IngredientReferences",
|
||||||
|
"RecipeStep",
|
||||||
"CreateIngredientFood",
|
"CreateIngredientFood",
|
||||||
"CreateIngredientFoodAlias",
|
"CreateIngredientFoodAlias",
|
||||||
"CreateIngredientUnit",
|
"CreateIngredientUnit",
|
||||||
@ -125,16 +159,7 @@ __all__ = [
|
|||||||
"SaveIngredientFood",
|
"SaveIngredientFood",
|
||||||
"SaveIngredientUnit",
|
"SaveIngredientUnit",
|
||||||
"UnitFoodBase",
|
"UnitFoodBase",
|
||||||
"ScrapeRecipe",
|
|
||||||
"ScrapeRecipeTest",
|
|
||||||
"RecipeImageTypes",
|
|
||||||
"IngredientReferences",
|
|
||||||
"RecipeStep",
|
|
||||||
"RecipeAsset",
|
"RecipeAsset",
|
||||||
"RecipeToolCreate",
|
|
||||||
"RecipeToolOut",
|
|
||||||
"RecipeToolResponse",
|
|
||||||
"RecipeToolSave",
|
|
||||||
"RecipeTimelineEventCreate",
|
"RecipeTimelineEventCreate",
|
||||||
"RecipeTimelineEventIn",
|
"RecipeTimelineEventIn",
|
||||||
"RecipeTimelineEventOut",
|
"RecipeTimelineEventOut",
|
||||||
@ -142,37 +167,12 @@ __all__ = [
|
|||||||
"RecipeTimelineEventUpdate",
|
"RecipeTimelineEventUpdate",
|
||||||
"TimelineEventImage",
|
"TimelineEventImage",
|
||||||
"TimelineEventType",
|
"TimelineEventType",
|
||||||
"CreateRecipe",
|
"RecipeDuplicate",
|
||||||
"CreateRecipeBulk",
|
"RecipeSlug",
|
||||||
"CreateRecipeByUrlBulk",
|
"RecipeZipTokenResponse",
|
||||||
"Recipe",
|
"SlugResponse",
|
||||||
"RecipeCategory",
|
"UpdateImageResponse",
|
||||||
"RecipeCategoryPagination",
|
"Nutrition",
|
||||||
"RecipeLastMade",
|
"RecipeSettings",
|
||||||
"RecipePagination",
|
"RecipeNote",
|
||||||
"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",
|
|
||||||
]
|
]
|
||||||
|
@ -99,7 +99,7 @@ class RecipeSummary(MealieModel):
|
|||||||
recipe_category: Annotated[list[RecipeCategory] | None, Field(validate_default=True)] | None = []
|
recipe_category: Annotated[list[RecipeCategory] | None, Field(validate_default=True)] | None = []
|
||||||
tags: Annotated[list[RecipeTag] | None, Field(validate_default=True)] = []
|
tags: Annotated[list[RecipeTag] | None, Field(validate_default=True)] = []
|
||||||
tools: list[RecipeTool] = []
|
tools: list[RecipeTool] = []
|
||||||
rating: int | None = None
|
rating: float | None = None
|
||||||
org_url: str | None = Field(None, alias="orgURL")
|
org_url: str | None = Field(None, alias="orgURL")
|
||||||
|
|
||||||
date_added: datetime.date | None = None
|
date_added: datetime.date | None = None
|
||||||
|
@ -11,6 +11,8 @@ __all__ = [
|
|||||||
"QueryFilterComponent",
|
"QueryFilterComponent",
|
||||||
"RelationalKeyword",
|
"RelationalKeyword",
|
||||||
"RelationalOperator",
|
"RelationalOperator",
|
||||||
|
"SearchFilter",
|
||||||
|
"ValidationResponse",
|
||||||
"OrderByNullPosition",
|
"OrderByNullPosition",
|
||||||
"OrderDirection",
|
"OrderDirection",
|
||||||
"PaginationBase",
|
"PaginationBase",
|
||||||
@ -19,6 +21,4 @@ __all__ = [
|
|||||||
"ErrorResponse",
|
"ErrorResponse",
|
||||||
"FileTokenResponse",
|
"FileTokenResponse",
|
||||||
"SuccessResponse",
|
"SuccessResponse",
|
||||||
"ValidationResponse",
|
|
||||||
"SearchFilter",
|
|
||||||
]
|
]
|
||||||
|
@ -14,11 +14,15 @@ from .user import (
|
|||||||
PrivateUser,
|
PrivateUser,
|
||||||
UpdateGroup,
|
UpdateGroup,
|
||||||
UserBase,
|
UserBase,
|
||||||
UserFavorites,
|
|
||||||
UserIn,
|
UserIn,
|
||||||
UserOut,
|
UserOut,
|
||||||
UserPagination,
|
UserPagination,
|
||||||
|
UserRatingCreate,
|
||||||
|
UserRatingOut,
|
||||||
|
UserRatings,
|
||||||
|
UserRatingSummary,
|
||||||
UserSummary,
|
UserSummary,
|
||||||
|
UserSummaryPagination,
|
||||||
)
|
)
|
||||||
from .user_passwords import (
|
from .user_passwords import (
|
||||||
ForgotPassword,
|
ForgotPassword,
|
||||||
@ -55,9 +59,13 @@ __all__ = [
|
|||||||
"PrivateUser",
|
"PrivateUser",
|
||||||
"UpdateGroup",
|
"UpdateGroup",
|
||||||
"UserBase",
|
"UserBase",
|
||||||
"UserFavorites",
|
|
||||||
"UserIn",
|
"UserIn",
|
||||||
"UserOut",
|
"UserOut",
|
||||||
"UserPagination",
|
"UserPagination",
|
||||||
|
"UserRatingCreate",
|
||||||
|
"UserRatingOut",
|
||||||
|
"UserRatingSummary",
|
||||||
|
"UserRatings",
|
||||||
"UserSummary",
|
"UserSummary",
|
||||||
|
"UserSummaryPagination",
|
||||||
]
|
]
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any, Generic, TypeVar
|
||||||
from uuid import UUID
|
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 import joinedload, selectinload
|
||||||
from sqlalchemy.orm.interfaces import LoaderOption
|
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._mealie import MealieModel
|
||||||
from mealie.schema.group.group_preferences import ReadGroupPreferences
|
from mealie.schema.group.group_preferences import ReadGroupPreferences
|
||||||
from mealie.schema.group.webhook import CreateWebhook, ReadWebhook
|
from mealie.schema.group.webhook import CreateWebhook, ReadWebhook
|
||||||
from mealie.schema.recipe import RecipeSummary
|
|
||||||
from mealie.schema.response.pagination import PaginationBase
|
from mealie.schema.response.pagination import PaginationBase
|
||||||
|
|
||||||
from ...db.models.group import Group
|
from ...db.models.group import Group
|
||||||
from ...db.models.recipe import RecipeModel
|
|
||||||
from ..recipe import CategoryBase
|
from ..recipe import CategoryBase
|
||||||
|
|
||||||
|
DataT = TypeVar("DataT", bound=BaseModel)
|
||||||
DEFAULT_INTEGRATION_ID = "generic"
|
DEFAULT_INTEGRATION_ID = "generic"
|
||||||
settings = get_app_settings()
|
settings = get_app_settings()
|
||||||
|
|
||||||
@ -58,6 +57,38 @@ class GroupBase(MealieModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
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):
|
class UserBase(MealieModel):
|
||||||
id: UUID4 | None = None
|
id: UUID4 | None = None
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
@ -67,7 +98,6 @@ class UserBase(MealieModel):
|
|||||||
admin: bool = False
|
admin: bool = False
|
||||||
group: str | None = None
|
group: str | None = None
|
||||||
advanced: bool = False
|
advanced: bool = False
|
||||||
favorite_recipes: list[str] | None = []
|
|
||||||
|
|
||||||
can_invite: bool = False
|
can_invite: bool = False
|
||||||
can_manage: bool = False
|
can_manage: bool = False
|
||||||
@ -107,7 +137,6 @@ class UserOut(UserBase):
|
|||||||
group_slug: str
|
group_slug: str
|
||||||
tokens: list[LongLiveTokenOut] | None = None
|
tokens: list[LongLiveTokenOut] | None = None
|
||||||
cache_key: str
|
cache_key: str
|
||||||
favorite_recipes: Annotated[list[str], Field(validate_default=True)] = []
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -116,27 +145,7 @@ class UserOut(UserBase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def loader_options(cls) -> list[LoaderOption]:
|
def loader_options(cls) -> list[LoaderOption]:
|
||||||
return [joinedload(User.group), joinedload(User.favorite_recipes), joinedload(User.tokens)]
|
return [joinedload(User.group), 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
|
|
||||||
|
|
||||||
|
|
||||||
class UserSummary(MealieModel):
|
class UserSummary(MealieModel):
|
||||||
@ -153,20 +162,6 @@ class UserSummaryPagination(PaginationBase):
|
|||||||
items: list[UserSummary]
|
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):
|
class PrivateUser(UserOut):
|
||||||
password: str
|
password: str
|
||||||
group_id: UUID4
|
group_id: UUID4
|
||||||
@ -198,7 +193,7 @@ class PrivateUser(UserOut):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def loader_options(cls) -> list[LoaderOption]:
|
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):
|
class UpdateGroup(GroupBase):
|
||||||
@ -244,7 +239,6 @@ class GroupInDB(UpdateGroup):
|
|||||||
joinedload(Group.webhooks),
|
joinedload(Group.webhooks),
|
||||||
joinedload(Group.preferences),
|
joinedload(Group.preferences),
|
||||||
selectinload(Group.users).joinedload(User.group),
|
selectinload(Group.users).joinedload(User.group),
|
||||||
selectinload(Group.users).joinedload(User.favorite_recipes),
|
|
||||||
selectinload(Group.users).joinedload(User.tokens),
|
selectinload(Group.users).joinedload(User.tokens),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -39,6 +39,5 @@ class PrivatePasswordResetToken(SavePasswordResetToken):
|
|||||||
def loader_options(cls) -> list[LoaderOption]:
|
def loader_options(cls) -> list[LoaderOption]:
|
||||||
return [
|
return [
|
||||||
selectinload(PasswordResetModel.user).joinedload(User.group),
|
selectinload(PasswordResetModel.user).joinedload(User.group),
|
||||||
selectinload(PasswordResetModel.user).joinedload(User.favorite_recipes),
|
|
||||||
selectinload(PasswordResetModel.user).joinedload(User.tokens),
|
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_step import RecipeStep
|
||||||
from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType
|
from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType
|
||||||
from mealie.schema.recipe.request_helpers import RecipeDuplicate
|
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._base_service import BaseService
|
||||||
from mealie.services.recipe.recipe_data_service import RecipeDataService
|
from mealie.services.recipe.recipe_data_service import RecipeDataService
|
||||||
|
|
||||||
@ -145,8 +145,20 @@ class RecipeService(BaseService):
|
|||||||
else:
|
else:
|
||||||
data.settings = RecipeSettings()
|
data.settings = RecipeSettings()
|
||||||
|
|
||||||
|
rating_input = data.rating
|
||||||
new_recipe = self.repos.recipes.create(data)
|
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
|
# create first timeline entry
|
||||||
timeline_event_data = RecipeTimelineEventCreate(
|
timeline_event_data = RecipeTimelineEventCreate(
|
||||||
user_id=new_recipe.user_id,
|
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 datetime import datetime
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
import pytest
|
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_category import CategoryOut, CategorySave, TagSave
|
||||||
from mealie.schema.recipe.recipe_tool import RecipeToolSave
|
from mealie.schema.recipe.recipe_tool import RecipeToolSave
|
||||||
from mealie.schema.response import OrderDirection, PaginationQuery
|
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.factories import random_email, random_string
|
||||||
from tests.utils.fixture_schemas import TestUser
|
from tests.utils.fixture_schemas import TestUser
|
||||||
|
|
||||||
@ -658,3 +659,126 @@ def test_random_order_recipe_search(
|
|||||||
pagination.pagination_seed = str(datetime.now())
|
pagination.pagination_seed = str(datetime.now())
|
||||||
random_ordered.append(repo.page_all(pagination, search="soup").items)
|
random_ordered.append(repo.page_all(pagination, search="soup").items)
|
||||||
assert not all(i == random_ordered[0] for i in random_ordered)
|
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 filecmp
|
||||||
|
import statistics
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
@ -8,11 +9,14 @@ from sqlalchemy.orm import Session
|
|||||||
import tests.data as test_data
|
import tests.data as test_data
|
||||||
from mealie.core.config import get_app_settings
|
from mealie.core.config import get_app_settings
|
||||||
from mealie.db.db_setup import session_context
|
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 import Group
|
||||||
from mealie.db.models.group.shopping_list import ShoppingList
|
from mealie.db.models.group.shopping_list import ShoppingList
|
||||||
from mealie.db.models.labels import MultiPurposeLabel
|
from mealie.db.models.labels import MultiPurposeLabel
|
||||||
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
|
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
|
||||||
from mealie.db.models.recipe.recipe import RecipeModel
|
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.alchemy_exporter import AlchemyExporter
|
||||||
from mealie.services.backups_v2.backup_file import BackupFile
|
from mealie.services.backups_v2.backup_file import BackupFile
|
||||||
from mealie.services.backups_v2.backup_v2 import BackupV2
|
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
|
assert unit.name_normalized
|
||||||
if unit.abbreviation:
|
if unit.abbreviation:
|
||||||
assert unit.abbreviation_normalized
|
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:
|
finally:
|
||||||
backup_v2.restore(original_data_backup)
|
backup_v2.restore(original_data_backup)
|
||||||
|
@ -181,8 +181,12 @@ users_reset_password = "/api/users/reset-password"
|
|||||||
"""`/api/users/reset-password`"""
|
"""`/api/users/reset-password`"""
|
||||||
users_self = "/api/users/self"
|
users_self = "/api/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"
|
users_self_group = "/api/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"
|
utils_download = "/api/utils/download"
|
||||||
"""`/api/utils/download`"""
|
"""`/api/utils/download`"""
|
||||||
validators_group = "/api/validators/group"
|
validators_group = "/api/validators/group"
|
||||||
@ -490,6 +494,21 @@ def users_id_image(id):
|
|||||||
return f"{prefix}/users/{id}/image"
|
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):
|
def users_item_id(item_id):
|
||||||
"""`/api/users/{item_id}`"""
|
"""`/api/users/{item_id}`"""
|
||||||
return f"{prefix}/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