diff --git a/alembic/versions/2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_.py b/alembic/versions/2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_.py
new file mode 100644
index 00000000000..f0e7972f6cd
--- /dev/null
+++ b/alembic/versions/2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_.py
@@ -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 ###
diff --git a/frontend/components/Domain/Group/GroupMealPlanRuleForm.vue b/frontend/components/Domain/Group/GroupMealPlanRuleForm.vue
index b475f26a9d7..cf71112a3ee 100644
--- a/frontend/components/Domain/Group/GroupMealPlanRuleForm.vue
+++ b/frontend/components/Domain/Group/GroupMealPlanRuleForm.vue
@@ -19,7 +19,7 @@
diff --git a/frontend/composables/recipes/use-recipe-tools.ts b/frontend/composables/recipes/use-recipe-tools.ts
index 24b811fafd3..83ea7ca4c6b 100644
--- a/frontend/composables/recipes/use-recipe-tools.ts
+++ b/frontend/composables/recipes/use-recipe-tools.ts
@@ -2,7 +2,7 @@ import { reactive, ref, useAsync } from "@nuxtjs/composition-api";
import { useAsyncKey } from "../use-utils";
import { useUserApi } from "~/composables/api";
import { VForm } from "~/types/vuetify";
-import { RecipeTool } from "~/lib/api/types/user";
+import { RecipeTool } from "~/lib/api/types/recipe";
export const useTools = function (eager = true) {
const workingToolData = reactive({
diff --git a/frontend/composables/store/use-category-store.ts b/frontend/composables/store/use-category-store.ts
index 211ff635ab3..4801bc9ab4b 100644
--- a/frontend/composables/store/use-category-store.ts
+++ b/frontend/composables/store/use-category-store.ts
@@ -2,7 +2,7 @@ import { reactive, ref, Ref } from "@nuxtjs/composition-api";
import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory";
import { usePublicExploreApi } from "../api/api-client";
import { useUserApi } from "~/composables/api";
-import { RecipeCategory } from "~/lib/api/types/admin";
+import { RecipeCategory } from "~/lib/api/types/recipe";
const categoryStore: Ref = ref([]);
const publicStoreLoading = ref(false);
diff --git a/frontend/composables/use-users/index.ts b/frontend/composables/use-users/index.ts
index f1b1a6a8571..5d3ebf01349 100644
--- a/frontend/composables/use-users/index.ts
+++ b/frontend/composables/use-users/index.ts
@@ -1,2 +1,3 @@
export { useUserForm } from "./user-form";
export { useUserRegistrationForm } from "./user-registration-form";
+export { useUserSelfRatings } from "./user-ratings";
diff --git a/frontend/composables/use-users/user-ratings.ts b/frontend/composables/use-users/user-ratings.ts
new file mode 100644
index 00000000000..cf29576786b
--- /dev/null
+++ b/frontend/composables/use-users/user-ratings.ts
@@ -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([]);
+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,
+ }
+}
diff --git a/frontend/lib/api/types/user.ts b/frontend/lib/api/types/user.ts
index 35ae18f132b..176c16161b0 100644
--- a/frontend/lib/api/types/user.ts
+++ b/frontend/lib/api/types/user.ts
@@ -5,10 +5,11 @@
/* Do not modify it by hand - just update the pydantic models and then re-run the script
*/
+export type WebhookType = "mealplan";
export type AuthMethod = "Mealie" | "LDAP" | "OIDC";
export interface ChangePassword {
- currentPassword: string;
+ currentPassword?: string;
newPassword: string;
}
export interface CreateToken {
@@ -30,6 +31,11 @@ export interface CreateUserRegistration {
seedData?: boolean;
locale?: string;
}
+export interface CredentialsRequest {
+ username: string;
+ password: string;
+ remember_me?: boolean;
+}
export interface DeleteTokenResponse {
tokenDelete: string;
}
@@ -44,7 +50,7 @@ export interface GroupInDB {
id: string;
slug: string;
categories?: CategoryBase[];
- webhooks?: unknown[];
+ webhooks?: ReadWebhook[];
users?: UserOut[];
preferences?: ReadGroupPreferences;
}
@@ -60,7 +66,17 @@ export interface CategoryBase {
id: string;
slug: string;
}
+export interface ReadWebhook {
+ enabled?: boolean;
+ name?: string;
+ url?: string;
+ webhookType?: WebhookType & string;
+ scheduledTime: string;
+ groupId: string;
+ id: string;
+}
export interface UserOut {
+ id: string;
username?: string;
fullName?: string;
email: string;
@@ -68,11 +84,9 @@ export interface UserOut {
admin?: boolean;
group: string;
advanced?: boolean;
- favoriteRecipes?: string[];
canInvite?: boolean;
canManage?: boolean;
canOrganize?: boolean;
- id: string;
groupId: string;
groupSlug: string;
tokens?: LongLiveTokenOut[];
@@ -109,6 +123,7 @@ export interface LongLiveTokenInDB {
user: PrivateUser;
}
export interface PrivateUser {
+ id: string;
username?: string;
fullName?: string;
email: string;
@@ -116,11 +131,9 @@ export interface PrivateUser {
admin?: boolean;
group: string;
advanced?: boolean;
- favoriteRecipes?: string[];
canInvite?: boolean;
canManage?: boolean;
canOrganize?: boolean;
- id: string;
groupId: string;
groupSlug: string;
tokens?: LongLiveTokenOut[];
@@ -129,6 +142,9 @@ export interface PrivateUser {
loginAttemps?: number;
lockedAt?: string;
}
+export interface OIDCRequest {
+ id_token: string;
+}
export interface PasswordResetToken {
token: string;
}
@@ -163,9 +179,17 @@ export interface UpdateGroup {
id: string;
slug: string;
categories?: CategoryBase[];
- webhooks?: unknown[];
+ webhooks?: CreateWebhook[];
+}
+export interface CreateWebhook {
+ enabled?: boolean;
+ name?: string;
+ url?: string;
+ webhookType?: WebhookType & string;
+ scheduledTime: string;
}
export interface UserBase {
+ id?: string;
username?: string;
fullName?: string;
email: string;
@@ -173,12 +197,12 @@ export interface UserBase {
admin?: boolean;
group?: string;
advanced?: boolean;
- favoriteRecipes?: string[];
canInvite?: boolean;
canManage?: boolean;
canOrganize?: boolean;
}
-export interface UserFavorites {
+export interface UserIn {
+ id?: string;
username?: string;
fullName?: string;
email: string;
@@ -186,68 +210,32 @@ export interface UserFavorites {
admin?: boolean;
group?: string;
advanced?: boolean;
- favoriteRecipes?: RecipeSummary[];
canInvite?: boolean;
canManage?: boolean;
canOrganize?: boolean;
+ password: string;
}
-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[];
+export interface UserRatingCreate {
+ recipeId: string;
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;
+ isFavorite?: boolean;
+ userId: string;
}
-export interface RecipeTool {
+export interface UserRatingOut {
+ recipeId: string;
+ rating?: number;
+ isFavorite?: boolean;
+ userId: string;
id: string;
- name: string;
- slug: string;
- onHand?: boolean;
}
-export interface UserIn {
- username?: string;
- fullName?: string;
- email: string;
- authMethod?: AuthMethod & string;
- admin?: boolean;
- group?: string;
- advanced?: boolean;
- favoriteRecipes?: string[];
- canInvite?: boolean;
- canManage?: boolean;
- canOrganize?: boolean;
- password: string;
+export interface UserRatingSummary {
+ recipeId: string;
+ rating?: number;
+ isFavorite?: boolean;
}
export interface UserSummary {
id: string;
- fullName?: string;
+ fullName: string;
}
export interface ValidateResetToken {
token: string;
diff --git a/frontend/lib/api/user/users.ts b/frontend/lib/api/user/users.ts
index 2a4810e94c8..9da932e8a84 100644
--- a/frontend/lib/api/user/users.ts
+++ b/frontend/lib/api/user/users.ts
@@ -9,17 +9,27 @@ import {
LongLiveTokenOut,
ResetPassword,
UserBase,
- UserFavorites,
UserIn,
UserOut,
+ UserRatingOut,
+ UserRatingSummary,
UserSummary,
} from "~/lib/api/types/user";
+export interface UserRatingsSummaries {
+ ratings: UserRatingSummary[];
+}
+
+export interface UserRatingsOut {
+ ratings: UserRatingOut[];
+}
+
const prefix = "/api";
const routes = {
groupUsers: `${prefix}/users/group-users`,
usersSelf: `${prefix}/users/self`,
+ ratingsSelf: `${prefix}/users/self/ratings`,
groupsSelf: `${prefix}/users/self/group`,
passwordReset: `${prefix}/users/reset-password`,
passwordChange: `${prefix}/users/password`,
@@ -30,6 +40,10 @@ const routes = {
usersId: (id: string) => `${prefix}/users/${id}`,
usersIdFavorites: (id: string) => `${prefix}/users/${id}/favorites`,
usersIdFavoritesSlug: (id: string, slug: string) => `${prefix}/users/${id}/favorites/${slug}`,
+ usersIdRatings: (id: string) => `${prefix}/users/${id}/ratings`,
+ usersIdRatingsSlug: (id: string, slug: string) => `${prefix}/users/${id}/ratings/${slug}`,
+ usersSelfFavoritesId: (id: string) => `${prefix}/users/self/favorites/${id}`,
+ usersSelfRatingsId: (id: string) => `${prefix}/users/self/ratings/${id}`,
usersApiTokens: `${prefix}/users/api-tokens`,
usersApiTokensTokenId: (token_id: string | number) => `${prefix}/users/api-tokens/${token_id}`,
@@ -56,7 +70,23 @@ export class UserApi extends BaseCRUDAPI {
}
async getFavorites(id: string) {
- return await this.requests.get(routes.usersIdFavorites(id));
+ return await this.requests.get(routes.usersIdFavorites(id));
+ }
+
+ async getSelfFavorites() {
+ return await this.requests.get(routes.ratingsSelf);
+ }
+
+ async getRatings(id: string) {
+ return await this.requests.get(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(routes.ratingsSelf);
}
async changePassword(changePassword: ChangePassword) {
diff --git a/frontend/pages/group/data/categories.vue b/frontend/pages/group/data/categories.vue
index 455985b6fd4..22ede431fae 100644
--- a/frontend/pages/group/data/categories.vue
+++ b/frontend/pages/group/data/categories.vue
@@ -96,7 +96,7 @@
import { defineComponent, reactive, ref, useContext } from "@nuxtjs/composition-api";
import { validators } from "~/composables/use-validators";
import { useCategoryStore, useCategoryData } from "~/composables/store";
-import { RecipeCategory } from "~/lib/api/types/admin";
+import { RecipeCategory } from "~/lib/api/types/recipe";
export default defineComponent({
setup() {
diff --git a/frontend/pages/user/_id/favorites.vue b/frontend/pages/user/_id/favorites.vue
index 603428d428e..e7114a2b9c2 100644
--- a/frontend/pages/user/_id/favorites.vue
+++ b/frontend/pages/user/_id/favorites.vue
@@ -1,7 +1,11 @@
-
-
+
@@ -21,14 +25,13 @@ export default defineComponent({
const { isOwnGroup } = useLoggedInState();
const userId = route.value.params.id;
-
- const user = useAsync(async () => {
- const { data } = await api.users.getFavorites(userId);
- return data;
+ const recipes = useAsync(async () => {
+ const { data } = await api.recipes.getAll(1, -1, { queryFilter: `favoritedBy.id = "${userId}"` });
+ return data?.items || null;
}, useAsyncKey());
return {
- user,
+ recipes,
isOwnGroup,
};
},
diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py
index 63632a6e304..7b62cbbb74e 100644
--- a/mealie/db/models/recipe/recipe.py
+++ b/mealie/db/models/recipe/recipe.py
@@ -7,12 +7,14 @@
from sqlalchemy import event
from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import Mapped, mapped_column, validates
+from sqlalchemy.orm.attributes import get_history
+from sqlalchemy.orm.session import object_session
from mealie.db.models._model_utils.guid import GUID
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
-from ..users.user_to_favorite import users_to_favorites
+from ..users.user_to_recipe import UserToRecipe
from .api_extras import ApiExtras, api_extras
from .assets import RecipeAsset
from .category import recipes_to_categories
@@ -49,12 +51,20 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
user_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("users.id", use_alter=True), index=True)
user: Mapped["User"] = orm.relationship("User", uselist=False, foreign_keys=[user_id])
- meal_entries: Mapped[list["GroupMealPlan"]] = orm.relationship(
- "GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan"
+ rating: Mapped[float | None] = mapped_column(sa.Float, index=True, nullable=True)
+ rated_by: Mapped[list["User"]] = orm.relationship(
+ "User", secondary=UserToRecipe.__tablename__, back_populates="rated_recipes"
)
-
favorited_by: Mapped[list["User"]] = orm.relationship(
- "User", secondary=users_to_favorites, back_populates="favorite_recipes"
+ "User",
+ secondary=UserToRecipe.__tablename__,
+ primaryjoin="and_(RecipeModel.id==UserToRecipe.recipe_id, UserToRecipe.is_favorite==True)",
+ back_populates="favorite_recipes",
+ viewonly=True,
+ )
+
+ meal_entries: Mapped[list["GroupMealPlan"]] = orm.relationship(
+ "GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan"
)
# General Recipe Properties
@@ -110,7 +120,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
)
tags: Mapped[list["Tag"]] = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes")
notes: Mapped[list[Note]] = orm.relationship("Note", cascade="all, delete-orphan")
- rating: Mapped[int | None] = mapped_column(sa.Integer)
org_url: Mapped[str | None] = mapped_column(sa.String)
extras: Mapped[list[ApiExtras]] = orm.relationship("ApiExtras", cascade="all, delete-orphan")
is_ocr_recipe: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
@@ -246,3 +255,23 @@ def receive_description(target: RecipeModel, value: str, oldvalue, initiator):
target.description_normalized = RecipeModel.normalize(value)
else:
target.description_normalized = None
+
+
+@event.listens_for(RecipeModel, "before_update")
+def calculate_rating(mapper, connection, target: RecipeModel):
+ session = object_session(target)
+ if not session:
+ return
+
+ if session.is_modified(target, "rating"):
+ history = get_history(target, "rating")
+ old_value = history.deleted[0] if history.deleted else None
+ new_value = history.added[0] if history.added else None
+ if old_value == new_value:
+ return
+
+ target.rating = (
+ session.query(sa.func.avg(UserToRecipe.rating))
+ .filter(UserToRecipe.recipe_id == target.id, UserToRecipe.rating is not None, UserToRecipe.rating > 0)
+ .scalar()
+ )
diff --git a/mealie/db/models/users/__init__.py b/mealie/db/models/users/__init__.py
index 586c7516a7c..9f181770f54 100644
--- a/mealie/db/models/users/__init__.py
+++ b/mealie/db/models/users/__init__.py
@@ -1,3 +1,3 @@
from .password_reset import *
-from .user_to_favorite import *
+from .user_to_recipe import *
from .users import *
diff --git a/mealie/db/models/users/user_to_favorite.py b/mealie/db/models/users/user_to_favorite.py
deleted file mode 100644
index b838a6194b9..00000000000
--- a/mealie/db/models/users/user_to_favorite.py
+++ /dev/null
@@ -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"),
-)
diff --git a/mealie/db/models/users/user_to_recipe.py b/mealie/db/models/users/user_to_recipe.py
new file mode 100644
index 00000000000..8fcda14ba1d
--- /dev/null
+++ b/mealie/db/models/users/user_to_recipe.py
@@ -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()
diff --git a/mealie/db/models/users/users.py b/mealie/db/models/users/users.py
index 65d507d7c33..64f46862ac4 100644
--- a/mealie/db/models/users/users.py
+++ b/mealie/db/models/users/users.py
@@ -12,7 +12,7 @@
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
-from .user_to_favorite import users_to_favorites
+from .user_to_recipe import UserToRecipe
if TYPE_CHECKING:
from ..group import Group
@@ -49,7 +49,7 @@ class User(SqlAlchemyBase, BaseMixins):
username: Mapped[str | None] = mapped_column(String, index=True, unique=True)
email: Mapped[str | None] = mapped_column(String, unique=True, index=True)
password: Mapped[str | None] = mapped_column(String)
- auth_method: Mapped[Enum(AuthMethod)] = mapped_column(Enum(AuthMethod), default=AuthMethod.MEALIE)
+ auth_method: Mapped[Enum[AuthMethod]] = mapped_column(Enum(AuthMethod), default=AuthMethod.MEALIE)
admin: Mapped[bool | None] = mapped_column(Boolean, default=False)
advanced: Mapped[bool | None] = mapped_column(Boolean, default=False)
@@ -84,8 +84,15 @@ class User(SqlAlchemyBase, BaseMixins):
"GroupMealPlan", order_by="GroupMealPlan.date", **sp_args
)
shopping_lists: Mapped[Optional["ShoppingList"]] = orm.relationship("ShoppingList", **sp_args)
+ rated_recipes: Mapped[list["RecipeModel"]] = orm.relationship(
+ "RecipeModel", secondary=UserToRecipe.__tablename__, back_populates="rated_by"
+ )
favorite_recipes: Mapped[list["RecipeModel"]] = orm.relationship(
- "RecipeModel", secondary=users_to_favorites, back_populates="favorited_by"
+ "RecipeModel",
+ secondary=UserToRecipe.__tablename__,
+ primaryjoin="and_(User.id==UserToRecipe.user_id, UserToRecipe.is_favorite==True)",
+ back_populates="favorited_by",
+ viewonly=True,
)
model_config = ConfigDict(
exclude={
@@ -112,7 +119,7 @@ def __init__(self, session, full_name, password, group: str | None = None, **kwa
self.group = Group.get_by_name(session, group)
- self.favorite_recipes = []
+ self.rated_recipes = []
self.password = password
diff --git a/mealie/repos/repository_factory.py b/mealie/repos/repository_factory.py
index f0a07d9ca19..701de158726 100644
--- a/mealie/repos/repository_factory.py
+++ b/mealie/repos/repository_factory.py
@@ -31,6 +31,7 @@
from mealie.db.models.server.task import ServerTaskModel
from mealie.db.models.users import LongLiveToken, User
from mealie.db.models.users.password_reset import PasswordResetModel
+from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.repos.repository_foods import RepositoryFood
from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
from mealie.repos.repository_units import RepositoryUnit
@@ -58,6 +59,7 @@
from mealie.schema.reports.reports import ReportEntryOut, ReportOut
from mealie.schema.server import ServerTask
from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser
+from mealie.schema.user.user import UserRatingOut
from mealie.schema.user.user_passwords import PrivatePasswordResetToken
from .repository_generic import RepositoryGeneric
@@ -65,7 +67,7 @@
from .repository_meals import RepositoryMeals
from .repository_recipes import RepositoryRecipes
from .repository_shopping_list import RepositoryShoppingList
-from .repository_users import RepositoryUsers
+from .repository_users import RepositoryUserRatings, RepositoryUsers
PK_ID = "id"
PK_SLUG = "slug"
@@ -143,6 +145,10 @@ def recipe_timeline_events(self) -> RepositoryGeneric[RecipeTimelineEventOut, Re
def users(self) -> RepositoryUsers:
return RepositoryUsers(self.session, PK_ID, User, PrivateUser)
+ @cached_property
+ def user_ratings(self) -> RepositoryUserRatings:
+ return RepositoryUserRatings(self.session, PK_ID, UserToRecipe, UserRatingOut)
+
@cached_property
def api_tokens(self) -> RepositoryGeneric[LongLiveTokenInDB, LongLiveToken]:
return RepositoryGeneric(self.session, PK_ID, LongLiveToken, LongLiveTokenInDB)
diff --git a/mealie/repos/repository_generic.py b/mealie/repos/repository_generic.py
index 026cbb6c251..e4d3aae97d5 100644
--- a/mealie/repos/repository_generic.py
+++ b/mealie/repos/repository_generic.py
@@ -8,6 +8,7 @@
from fastapi import HTTPException
from pydantic import UUID4, BaseModel
from sqlalchemy import Select, case, delete, func, nulls_first, nulls_last, select
+from sqlalchemy.orm import InstrumentedAttribute
from sqlalchemy.orm.session import Session
from sqlalchemy.sql import sqltypes
@@ -67,9 +68,6 @@ def _query(self, override_schema: type[MealieModel] | None = None, with_options=
def _filter_builder(self, **kwargs) -> dict[str, Any]:
dct = {}
- if self.user_id:
- dct["user_id"] = self.user_id
-
if self.group_id:
dct["group_id"] = self.group_id
@@ -287,7 +285,7 @@ def page_all(self, pagination: PaginationQuery, override=None, search: str | Non
pagination is a method to interact with the filtered database table and return a paginated result
using the PaginationBase that provides several data points that are needed to manage pagination
on the client side. This method does utilize the _filter_build method to ensure that the results
- are filtered by the user and group id when applicable.
+ are filtered by the group id when applicable.
NOTE: When you provide an override you'll need to manually type the result of this method
as the override, as the type system is not able to infer the result of this method.
@@ -368,6 +366,29 @@ def add_pagination_to_query(self, query: Select, pagination: PaginationQuery) ->
query = self.add_order_by_to_query(query, pagination)
return query.limit(pagination.per_page).offset((pagination.page - 1) * pagination.per_page), count, total_pages
+ def add_order_attr_to_query(
+ self,
+ query: Select,
+ order_attr: InstrumentedAttribute,
+ order_dir: OrderDirection,
+ order_by_null: OrderByNullPosition | None,
+ ) -> Select:
+ if order_dir is OrderDirection.asc:
+ order_attr = order_attr.asc()
+ elif order_dir is OrderDirection.desc:
+ order_attr = order_attr.desc()
+
+ # queries handle uppercase and lowercase differently, which is undesirable
+ if isinstance(order_attr.type, sqltypes.String):
+ order_attr = func.lower(order_attr)
+
+ if order_by_null is OrderByNullPosition.first:
+ order_attr = nulls_first(order_attr)
+ elif order_by_null is OrderByNullPosition.last:
+ order_attr = nulls_last(order_attr)
+
+ return query.order_by(order_attr)
+
def add_order_by_to_query(self, query: Select, pagination: PaginationQuery) -> Select:
if not pagination.order_by:
return query
@@ -399,21 +420,9 @@ def add_order_by_to_query(self, query: Select, pagination: PaginationQuery) -> S
order_by, self.model, query=query
)
- if order_dir is OrderDirection.asc:
- order_attr = order_attr.asc()
- elif order_dir is OrderDirection.desc:
- order_attr = order_attr.desc()
-
- # queries handle uppercase and lowercase differently, which is undesirable
- if isinstance(order_attr.type, sqltypes.String):
- order_attr = func.lower(order_attr)
-
- if pagination.order_by_null_position is OrderByNullPosition.first:
- order_attr = nulls_first(order_attr)
- elif pagination.order_by_null_position is OrderByNullPosition.last:
- order_attr = nulls_last(order_attr)
-
- query = query.order_by(order_attr)
+ query = self.add_order_attr_to_query(
+ query, order_attr, order_dir, pagination.order_by_null_position
+ )
except ValueError as e:
raise HTTPException(
diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py
index 7fbe99879fd..8569adcdb97 100644
--- a/mealie/repos/repository_recipes.py
+++ b/mealie/repos/repository_recipes.py
@@ -3,11 +3,11 @@
from random import randint
from uuid import UUID
+import sqlalchemy as sa
from pydantic import UUID4
from slugify import slugify
-from sqlalchemy import and_, func, select
from sqlalchemy.exc import IntegrityError
-from sqlalchemy.orm import joinedload
+from sqlalchemy.orm import InstrumentedAttribute, joinedload
from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.ingredient import RecipeIngredientModel
@@ -15,11 +15,12 @@
from mealie.db.models.recipe.settings import RecipeSettings
from mealie.db.models.recipe.tag import Tag
from mealie.db.models.recipe.tool import Tool
+from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary, RecipeTag, RecipeTool
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
-from mealie.schema.response.pagination import PaginationQuery
+from mealie.schema.response.pagination import OrderByNullPosition, OrderDirection, PaginationQuery
from ..db.models._model_base import SqlAlchemyBase
from ..schema._mealie.mealie_model import extract_uuids
@@ -51,7 +52,7 @@ def get_all_public(self, limit: int | None = None, order_by: str | None = None,
if order_by:
order_attr = getattr(self.model, str(order_by))
stmt = (
- select(self.model)
+ sa.select(self.model)
.join(RecipeSettings)
.filter(RecipeSettings.public == True) # noqa: E712
.order_by(order_attr.desc())
@@ -61,7 +62,7 @@ def get_all_public(self, limit: int | None = None, order_by: str | None = None,
return [eff_schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
stmt = (
- select(self.model)
+ sa.select(self.model)
.join(RecipeSettings)
.filter(RecipeSettings.public == True) # noqa: E712
.offset(start)
@@ -121,7 +122,7 @@ def summary(
order_attr = order_attr.asc()
stmt = (
- select(RecipeModel)
+ sa.select(RecipeModel)
.options(*args)
.filter(RecipeModel.group_id == group_id)
.order_by(order_attr)
@@ -145,9 +146,54 @@ def _uuids_for_items(self, items: list[UUID | str] | None, model: type[SqlAlchem
ids.append(i_as_uuid)
except ValueError:
slugs.append(i)
- additional_ids = self.session.execute(select(model.id).filter(model.slug.in_(slugs))).scalars().all()
+ additional_ids = self.session.execute(sa.select(model.id).filter(model.slug.in_(slugs))).scalars().all()
return ids + additional_ids
+ def add_order_attr_to_query(
+ self,
+ query: sa.Select,
+ order_attr: InstrumentedAttribute,
+ order_dir: OrderDirection,
+ order_by_null: OrderByNullPosition | None,
+ ) -> sa.Select:
+ """Special handling for ordering recipes by rating"""
+ column_name = order_attr.key
+ if column_name != "rating" or not self.user_id:
+ return super().add_order_attr_to_query(query, order_attr, order_dir, order_by_null)
+
+ # calculate the effictive rating for the user by using the user's rating if it exists,
+ # falling back to the recipe's rating if it doesn't
+ effective_rating_column_name = "_effective_rating"
+ query = query.add_columns(
+ sa.case(
+ (
+ sa.exists().where(
+ UserToRecipe.recipe_id == self.model.id,
+ UserToRecipe.user_id == self.user_id,
+ UserToRecipe.rating is not None,
+ UserToRecipe.rating > 0,
+ ),
+ sa.select(UserToRecipe.rating)
+ .where(UserToRecipe.recipe_id == self.model.id, UserToRecipe.user_id == self.user_id)
+ .scalar_subquery(),
+ ),
+ else_=self.model.rating,
+ ).label(effective_rating_column_name)
+ )
+
+ order_attr = effective_rating_column_name
+ if order_dir is OrderDirection.asc:
+ order_attr = sa.asc(order_attr)
+ elif order_dir is OrderDirection.desc:
+ order_attr = sa.desc(order_attr)
+
+ if order_by_null is OrderByNullPosition.first:
+ order_attr = sa.nulls_first(order_attr)
+ elif order_by_null is OrderByNullPosition.last:
+ order_attr = sa.nulls_last(order_attr)
+
+ return query.order_by(order_attr)
+
def page_all( # type: ignore
self,
pagination: PaginationQuery,
@@ -165,7 +211,7 @@ def page_all( # type: ignore
) -> RecipePagination:
# Copy this, because calling methods (e.g. tests) might rely on it not getting mutated
pagination_result = pagination.model_copy()
- q = select(self.model)
+ q = sa.select(self.model)
args = [
joinedload(RecipeModel.recipe_category),
@@ -236,7 +282,7 @@ def get_by_categories(self, categories: list[RecipeCategory]) -> list[RecipeSumm
ids = [x.id for x in categories]
stmt = (
- select(RecipeModel)
+ sa.select(RecipeModel)
.join(RecipeModel.recipe_category)
.filter(RecipeModel.recipe_category.any(Category.id.in_(ids)))
)
@@ -301,7 +347,7 @@ def by_category_and_tags(
require_all_tags=require_all_tags,
require_all_tools=require_all_tools,
)
- stmt = select(RecipeModel).filter(*fltr)
+ stmt = sa.select(RecipeModel).filter(*fltr)
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
def get_random_by_categories_and_tags(
@@ -318,26 +364,29 @@ def get_random_by_categories_and_tags(
filters = self._build_recipe_filter(extract_uuids(categories), extract_uuids(tags)) # type: ignore
stmt = (
- select(RecipeModel).filter(and_(*filters)).order_by(func.random()).limit(1) # Postgres and SQLite specific
+ sa.select(RecipeModel)
+ .filter(sa.and_(*filters))
+ .order_by(sa.func.random())
+ .limit(1) # Postgres and SQLite specific
)
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
def get_random(self, limit=1) -> list[Recipe]:
stmt = (
- select(RecipeModel)
+ sa.select(RecipeModel)
.filter(RecipeModel.group_id == self.group_id)
- .order_by(func.random()) # Postgres and SQLite specific
+ .order_by(sa.func.random()) # Postgres and SQLite specific
.limit(limit)
)
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
- def get_by_slug(self, group_id: UUID4, slug: str, limit=1) -> Recipe | None:
- stmt = select(RecipeModel).filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug)
+ def get_by_slug(self, group_id: UUID4, slug: str) -> Recipe | None:
+ stmt = sa.select(RecipeModel).filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug)
dbrecipe = self.session.execute(stmt).scalars().one_or_none()
if dbrecipe is None:
return None
return self.schema.model_validate(dbrecipe)
def all_ids(self, group_id: UUID4) -> Sequence[UUID4]:
- stmt = select(RecipeModel.id).filter(RecipeModel.group_id == group_id)
+ stmt = sa.select(RecipeModel.id).filter(RecipeModel.group_id == group_id)
return self.session.execute(stmt).scalars().all()
diff --git a/mealie/repos/repository_users.py b/mealie/repos/repository_users.py
index fafd2060b2d..eaa8dcd5c29 100644
--- a/mealie/repos/repository_users.py
+++ b/mealie/repos/repository_users.py
@@ -6,7 +6,8 @@
from mealie.assets import users as users_assets
from mealie.core.config import get_app_settings
-from mealie.schema.user.user import PrivateUser
+from mealie.db.models.users.user_to_recipe import UserToRecipe
+from mealie.schema.user.user import PrivateUser, UserRatingOut
from ..db.models.users import User
from .repository_generic import RepositoryGeneric
@@ -72,3 +73,26 @@ def get_locked_users(self) -> list[PrivateUser]:
stmt = select(User).filter(User.locked_at != None) # noqa E711
results = self.session.execute(stmt).scalars().all()
return [self.schema.model_validate(x) for x in results]
+
+
+class RepositoryUserRatings(RepositoryGeneric[UserRatingOut, UserToRecipe]):
+ def get_by_user(self, user_id: UUID4, favorites_only=False) -> list[UserRatingOut]:
+ stmt = select(UserToRecipe).filter(UserToRecipe.user_id == user_id)
+ if favorites_only:
+ stmt = stmt.filter(UserToRecipe.is_favorite)
+
+ results = self.session.execute(stmt).scalars().all()
+ return [self.schema.model_validate(x) for x in results]
+
+ def get_by_recipe(self, recipe_id: UUID4, favorites_only=False) -> list[UserRatingOut]:
+ stmt = select(UserToRecipe).filter(UserToRecipe.recipe_id == recipe_id)
+ if favorites_only:
+ stmt = stmt.filter(UserToRecipe.is_favorite)
+
+ results = self.session.execute(stmt).scalars().all()
+ return [self.schema.model_validate(x) for x in results]
+
+ def get_by_user_and_recipe(self, user_id: UUID4, recipe_id: UUID4) -> UserRatingOut | None:
+ stmt = select(UserToRecipe).filter(UserToRecipe.user_id == user_id, UserToRecipe.recipe_id == recipe_id)
+ result = self.session.execute(stmt).scalars().one_or_none()
+ return None if result is None else self.schema.model_validate(result)
diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py
index 831a167a3f5..a10131a415d 100644
--- a/mealie/routes/recipe/recipe_crud_routes.py
+++ b/mealie/routes/recipe/recipe_crud_routes.py
@@ -258,7 +258,8 @@ def get_all(
if cookbook_data is None:
raise HTTPException(status_code=404, detail="cookbook not found")
- pagination_response = self.repo.page_all(
+ # we use the repo by user so we can sort favorites correctly
+ pagination_response = self.repo.by_user(self.user.id).page_all(
pagination=q,
cookbook=cookbook_data,
categories=categories,
diff --git a/mealie/routes/users/__init__.py b/mealie/routes/users/__init__.py
index aec44e11219..f46c8a54ac0 100644
--- a/mealie/routes/users/__init__.py
+++ b/mealie/routes/users/__init__.py
@@ -1,6 +1,6 @@
from fastapi import APIRouter
-from . import api_tokens, crud, favorites, forgot_password, images, registration
+from . import api_tokens, crud, forgot_password, images, ratings, registration
# Must be used because of the way FastAPI works with nested routes
user_prefix = "/users"
@@ -13,4 +13,4 @@
router.include_router(forgot_password.router, prefix=user_prefix, tags=["Users: Passwords"])
router.include_router(images.router, prefix=user_prefix, tags=["Users: Images"])
router.include_router(api_tokens.router)
-router.include_router(favorites.router, prefix=user_prefix, tags=["Users: Favorites"])
+router.include_router(ratings.router, prefix=user_prefix, tags=["Users: Ratings"])
diff --git a/mealie/routes/users/crud.py b/mealie/routes/users/crud.py
index 27aa8f95de3..7c3a8b3601a 100644
--- a/mealie/routes/users/crud.py
+++ b/mealie/routes/users/crud.py
@@ -11,7 +11,14 @@
from mealie.schema.response import ErrorResponse, SuccessResponse
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.user import ChangePassword, UserBase, UserIn, UserOut
-from mealie.schema.user.user import GroupInDB, UserPagination, UserSummary, UserSummaryPagination
+from mealie.schema.user.user import (
+ GroupInDB,
+ UserPagination,
+ UserRatings,
+ UserRatingSummary,
+ UserSummary,
+ UserSummaryPagination,
+)
user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"])
admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"])
@@ -74,6 +81,25 @@ def get_all_group_users(self, q: PaginationQuery = Depends(PaginationQuery)):
def get_logged_in_user(self):
return self.user
+ @user_router.get("/self/ratings", response_model=UserRatings[UserRatingSummary])
+ def get_logged_in_user_ratings(self):
+ return UserRatings(ratings=self.repos.user_ratings.get_by_user(self.user.id))
+
+ @user_router.get("/self/ratings/{recipe_id}", response_model=UserRatingSummary)
+ def get_logged_in_user_rating_for_recipe(self, recipe_id: UUID4):
+ user_rating = self.repos.user_ratings.get_by_user_and_recipe(self.user.id, recipe_id)
+ if user_rating:
+ return user_rating
+ else:
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND,
+ ErrorResponse.respond("User has not rated this recipe"),
+ )
+
+ @user_router.get("/self/favorites", response_model=UserRatings[UserRatingSummary])
+ def get_logged_in_user_favorites(self):
+ return UserRatings(ratings=self.repos.user_ratings.get_by_user(self.user.id, favorites_only=True))
+
@user_router.get("/self/group", response_model=GroupInDB)
def get_logged_in_user_group(self):
return self.group
diff --git a/mealie/routes/users/favorites.py b/mealie/routes/users/favorites.py
deleted file mode 100644
index 14f06075466..00000000000
--- a/mealie/routes/users/favorites.py
+++ /dev/null
@@ -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
diff --git a/mealie/routes/users/ratings.py b/mealie/routes/users/ratings.py
new file mode 100644
index 00000000000..67a2f2b6ba4
--- /dev/null
+++ b/mealie/routes/users/ratings.py
@@ -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))
diff --git a/mealie/schema/_mealie/__init__.py b/mealie/schema/_mealie/__init__.py
index 6712561a6cc..7441e747a51 100644
--- a/mealie/schema/_mealie/__init__.py
+++ b/mealie/schema/_mealie/__init__.py
@@ -1,8 +1,13 @@
# This file is auto-generated by gen_schema_exports.py
+from .datetime_parse import DateError, DateTimeError, DurationError, TimeError
from .mealie_model import HasUUID, MealieModel, SearchType
__all__ = [
"HasUUID",
"MealieModel",
"SearchType",
+ "DateError",
+ "DateTimeError",
+ "DurationError",
+ "TimeError",
]
diff --git a/mealie/schema/admin/__init__.py b/mealie/schema/admin/__init__.py
index b2bf2b3288c..5e255d7bb98 100644
--- a/mealie/schema/admin/__init__.py
+++ b/mealie/schema/admin/__init__.py
@@ -17,19 +17,22 @@
from .settings import CustomPageBase, CustomPageOut
__all__ = [
- "AllBackups",
- "BackupFile",
- "BackupOptions",
- "CreateBackup",
- "ImportJob",
+ "MaintenanceLogs",
+ "MaintenanceStorageDetails",
+ "MaintenanceSummary",
+ "CommentImport",
+ "CustomPageImport",
+ "GroupImport",
+ "ImportBase",
+ "NotificationImport",
+ "RecipeImport",
+ "SettingsImport",
+ "UserImport",
"EmailReady",
"EmailSuccess",
"EmailTest",
"CustomPageBase",
"CustomPageOut",
- "MaintenanceLogs",
- "MaintenanceStorageDetails",
- "MaintenanceSummary",
"AdminAboutInfo",
"AppInfo",
"AppStartupInfo",
@@ -37,16 +40,13 @@
"AppTheme",
"CheckAppConfig",
"OIDCInfo",
- "CommentImport",
- "CustomPageImport",
- "GroupImport",
- "ImportBase",
- "NotificationImport",
- "RecipeImport",
- "SettingsImport",
- "UserImport",
"ChowdownURL",
"MigrationFile",
"MigrationImport",
"Migrations",
+ "AllBackups",
+ "BackupFile",
+ "BackupOptions",
+ "CreateBackup",
+ "ImportJob",
]
diff --git a/mealie/schema/group/__init__.py b/mealie/schema/group/__init__.py
index 13f0ff8bfcc..7dce2767c9c 100644
--- a/mealie/schema/group/__init__.py
+++ b/mealie/schema/group/__init__.py
@@ -45,36 +45,6 @@
from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType
__all__ = [
- "CreateWebhook",
- "ReadWebhook",
- "SaveWebhook",
- "WebhookPagination",
- "WebhookType",
- "GroupDataExport",
- "GroupEventNotifierCreate",
- "GroupEventNotifierOptions",
- "GroupEventNotifierOptionsOut",
- "GroupEventNotifierOptionsSave",
- "GroupEventNotifierOut",
- "GroupEventNotifierPrivate",
- "GroupEventNotifierSave",
- "GroupEventNotifierUpdate",
- "GroupEventPagination",
- "CreateGroupPreferences",
- "ReadGroupPreferences",
- "UpdateGroupPreferences",
- "GroupStatistics",
- "GroupStorage",
- "GroupAdminUpdate",
- "DataMigrationCreate",
- "SupportedMigrations",
- "SeederConfig",
- "SetPermissions",
- "CreateInviteToken",
- "EmailInitationResponse",
- "EmailInvitation",
- "ReadInviteToken",
- "SaveInviteToken",
"ShoppingListAddRecipeParams",
"ShoppingListCreate",
"ShoppingListItemBase",
@@ -97,4 +67,34 @@
"ShoppingListSave",
"ShoppingListSummary",
"ShoppingListUpdate",
+ "CreateWebhook",
+ "ReadWebhook",
+ "SaveWebhook",
+ "WebhookPagination",
+ "WebhookType",
+ "GroupAdminUpdate",
+ "CreateGroupPreferences",
+ "ReadGroupPreferences",
+ "UpdateGroupPreferences",
+ "SetPermissions",
+ "DataMigrationCreate",
+ "SupportedMigrations",
+ "SeederConfig",
+ "GroupDataExport",
+ "CreateInviteToken",
+ "EmailInitationResponse",
+ "EmailInvitation",
+ "ReadInviteToken",
+ "SaveInviteToken",
+ "GroupStatistics",
+ "GroupStorage",
+ "GroupEventNotifierCreate",
+ "GroupEventNotifierOptions",
+ "GroupEventNotifierOptionsOut",
+ "GroupEventNotifierOptionsSave",
+ "GroupEventNotifierOut",
+ "GroupEventNotifierPrivate",
+ "GroupEventNotifierSave",
+ "GroupEventNotifierUpdate",
+ "GroupEventPagination",
]
diff --git a/mealie/schema/meal_plan/__init__.py b/mealie/schema/meal_plan/__init__.py
index d99fc75c65d..3f757943c65 100644
--- a/mealie/schema/meal_plan/__init__.py
+++ b/mealie/schema/meal_plan/__init__.py
@@ -30,6 +30,9 @@
"PlanRulesSave",
"PlanRulesType",
"Tag",
+ "ListItem",
+ "ShoppingListIn",
+ "ShoppingListOut",
"CreatePlanEntry",
"CreateRandomEntry",
"PlanEntryPagination",
@@ -37,9 +40,6 @@
"ReadPlanEntry",
"SavePlanEntry",
"UpdatePlanEntry",
- "ListItem",
- "ShoppingListIn",
- "ShoppingListOut",
"MealDayIn",
"MealDayOut",
"MealIn",
diff --git a/mealie/schema/recipe/__init__.py b/mealie/schema/recipe/__init__.py
index b2efd25d58a..bff38c44961 100644
--- a/mealie/schema/recipe/__init__.py
+++ b/mealie/schema/recipe/__init__.py
@@ -88,8 +88,20 @@
from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse
__all__ = [
- "Nutrition",
- "RecipeSettings",
+ "RecipeToolCreate",
+ "RecipeToolOut",
+ "RecipeToolResponse",
+ "RecipeToolSave",
+ "CategoryBase",
+ "CategoryIn",
+ "CategoryOut",
+ "CategorySave",
+ "RecipeCategoryResponse",
+ "RecipeTagResponse",
+ "TagBase",
+ "TagIn",
+ "TagOut",
+ "TagSave",
"AssignCategories",
"AssignSettings",
"AssignTags",
@@ -97,12 +109,34 @@
"ExportBase",
"ExportRecipes",
"ExportTypes",
- "RecipeNote",
- "RecipeDuplicate",
- "RecipeSlug",
- "RecipeZipTokenResponse",
- "SlugResponse",
- "UpdateImageResponse",
+ "RecipeShareToken",
+ "RecipeShareTokenCreate",
+ "RecipeShareTokenSave",
+ "RecipeShareTokenSummary",
+ "ScrapeRecipe",
+ "ScrapeRecipeTest",
+ "RecipeCommentCreate",
+ "RecipeCommentOut",
+ "RecipeCommentPagination",
+ "RecipeCommentSave",
+ "RecipeCommentUpdate",
+ "UserBase",
+ "RecipeImageTypes",
+ "CreateRecipe",
+ "CreateRecipeBulk",
+ "CreateRecipeByUrlBulk",
+ "Recipe",
+ "RecipeCategory",
+ "RecipeCategoryPagination",
+ "RecipeLastMade",
+ "RecipePagination",
+ "RecipeSummary",
+ "RecipeTag",
+ "RecipeTagPagination",
+ "RecipeTool",
+ "RecipeToolPagination",
+ "IngredientReferences",
+ "RecipeStep",
"CreateIngredientFood",
"CreateIngredientFoodAlias",
"CreateIngredientUnit",
@@ -125,16 +159,7 @@
"SaveIngredientFood",
"SaveIngredientUnit",
"UnitFoodBase",
- "ScrapeRecipe",
- "ScrapeRecipeTest",
- "RecipeImageTypes",
- "IngredientReferences",
- "RecipeStep",
"RecipeAsset",
- "RecipeToolCreate",
- "RecipeToolOut",
- "RecipeToolResponse",
- "RecipeToolSave",
"RecipeTimelineEventCreate",
"RecipeTimelineEventIn",
"RecipeTimelineEventOut",
@@ -142,37 +167,12 @@
"RecipeTimelineEventUpdate",
"TimelineEventImage",
"TimelineEventType",
- "CreateRecipe",
- "CreateRecipeBulk",
- "CreateRecipeByUrlBulk",
- "Recipe",
- "RecipeCategory",
- "RecipeCategoryPagination",
- "RecipeLastMade",
- "RecipePagination",
- "RecipeSummary",
- "RecipeTag",
- "RecipeTagPagination",
- "RecipeTool",
- "RecipeToolPagination",
- "CategoryBase",
- "CategoryIn",
- "CategoryOut",
- "CategorySave",
- "RecipeCategoryResponse",
- "RecipeTagResponse",
- "TagBase",
- "TagIn",
- "TagOut",
- "TagSave",
- "RecipeCommentCreate",
- "RecipeCommentOut",
- "RecipeCommentPagination",
- "RecipeCommentSave",
- "RecipeCommentUpdate",
- "UserBase",
- "RecipeShareToken",
- "RecipeShareTokenCreate",
- "RecipeShareTokenSave",
- "RecipeShareTokenSummary",
+ "RecipeDuplicate",
+ "RecipeSlug",
+ "RecipeZipTokenResponse",
+ "SlugResponse",
+ "UpdateImageResponse",
+ "Nutrition",
+ "RecipeSettings",
+ "RecipeNote",
]
diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py
index e0914ed080a..8120ba53fbe 100644
--- a/mealie/schema/recipe/recipe.py
+++ b/mealie/schema/recipe/recipe.py
@@ -99,7 +99,7 @@ class RecipeSummary(MealieModel):
recipe_category: Annotated[list[RecipeCategory] | None, Field(validate_default=True)] | None = []
tags: Annotated[list[RecipeTag] | None, Field(validate_default=True)] = []
tools: list[RecipeTool] = []
- rating: int | None = None
+ rating: float | None = None
org_url: str | None = Field(None, alias="orgURL")
date_added: datetime.date | None = None
diff --git a/mealie/schema/response/__init__.py b/mealie/schema/response/__init__.py
index bc0e5858876..1a498e72e4a 100644
--- a/mealie/schema/response/__init__.py
+++ b/mealie/schema/response/__init__.py
@@ -11,6 +11,8 @@
"QueryFilterComponent",
"RelationalKeyword",
"RelationalOperator",
+ "SearchFilter",
+ "ValidationResponse",
"OrderByNullPosition",
"OrderDirection",
"PaginationBase",
@@ -19,6 +21,4 @@
"ErrorResponse",
"FileTokenResponse",
"SuccessResponse",
- "ValidationResponse",
- "SearchFilter",
]
diff --git a/mealie/schema/user/__init__.py b/mealie/schema/user/__init__.py
index a9524787c39..0ea9b2a20db 100644
--- a/mealie/schema/user/__init__.py
+++ b/mealie/schema/user/__init__.py
@@ -14,11 +14,15 @@
PrivateUser,
UpdateGroup,
UserBase,
- UserFavorites,
UserIn,
UserOut,
UserPagination,
+ UserRatingCreate,
+ UserRatingOut,
+ UserRatings,
+ UserRatingSummary,
UserSummary,
+ UserSummaryPagination,
)
from .user_passwords import (
ForgotPassword,
@@ -55,9 +59,13 @@
"PrivateUser",
"UpdateGroup",
"UserBase",
- "UserFavorites",
"UserIn",
"UserOut",
"UserPagination",
+ "UserRatingCreate",
+ "UserRatingOut",
+ "UserRatingSummary",
+ "UserRatings",
"UserSummary",
+ "UserSummaryPagination",
]
diff --git a/mealie/schema/user/user.py b/mealie/schema/user/user.py
index a478866a523..19ba5128a21 100644
--- a/mealie/schema/user/user.py
+++ b/mealie/schema/user/user.py
@@ -1,9 +1,9 @@
from datetime import datetime, timedelta
from pathlib import Path
-from typing import Annotated, Any
+from typing import Annotated, Any, Generic, TypeVar
from uuid import UUID
-from pydantic import UUID4, ConfigDict, Field, StringConstraints, field_validator
+from pydantic import UUID4, BaseModel, ConfigDict, Field, StringConstraints, field_validator
from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm.interfaces import LoaderOption
@@ -13,13 +13,12 @@
from mealie.schema._mealie import MealieModel
from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.group.webhook import CreateWebhook, ReadWebhook
-from mealie.schema.recipe import RecipeSummary
from mealie.schema.response.pagination import PaginationBase
from ...db.models.group import Group
-from ...db.models.recipe import RecipeModel
from ..recipe import CategoryBase
+DataT = TypeVar("DataT", bound=BaseModel)
DEFAULT_INTEGRATION_ID = "generic"
settings = get_app_settings()
@@ -58,6 +57,38 @@ class GroupBase(MealieModel):
model_config = ConfigDict(from_attributes=True)
+class UserRatingSummary(MealieModel):
+ recipe_id: UUID4
+ rating: float | None = None
+ is_favorite: Annotated[bool, Field(validate_default=True)] = False
+
+ model_config = ConfigDict(from_attributes=True)
+
+ @field_validator("is_favorite", mode="before")
+ def convert_is_favorite(cls, v: Any) -> bool:
+ if v is None:
+ return False
+ else:
+ return v
+
+
+class UserRatingCreate(UserRatingSummary):
+ user_id: UUID4
+
+
+class UserRatingUpdate(MealieModel):
+ rating: float | None = None
+ is_favorite: bool | None = None
+
+
+class UserRatingOut(UserRatingCreate):
+ id: UUID4
+
+
+class UserRatings(BaseModel, Generic[DataT]):
+ ratings: list[DataT]
+
+
class UserBase(MealieModel):
id: UUID4 | None = None
username: str | None = None
@@ -67,7 +98,6 @@ class UserBase(MealieModel):
admin: bool = False
group: str | None = None
advanced: bool = False
- favorite_recipes: list[str] | None = []
can_invite: bool = False
can_manage: bool = False
@@ -107,7 +137,6 @@ class UserOut(UserBase):
group_slug: str
tokens: list[LongLiveTokenOut] | None = None
cache_key: str
- favorite_recipes: Annotated[list[str], Field(validate_default=True)] = []
model_config = ConfigDict(from_attributes=True)
@property
@@ -116,27 +145,7 @@ def is_default_user(self) -> bool:
@classmethod
def loader_options(cls) -> list[LoaderOption]:
- return [joinedload(User.group), joinedload(User.favorite_recipes), joinedload(User.tokens)]
-
- @field_validator("favorite_recipes", mode="before")
- def convert_favorite_recipes_to_slugs(cls, v: Any):
- if not v:
- return []
- if not isinstance(v, list):
- return v
-
- slugs: list[str] = []
- for recipe in v:
- if isinstance(recipe, str):
- slugs.append(recipe)
- else:
- try:
- slugs.append(recipe.slug)
- except AttributeError:
- # this isn't a list of recipes, so we quit early and let Pydantic's typical validation handle it
- return v
-
- return slugs
+ return [joinedload(User.group), joinedload(User.tokens)]
class UserSummary(MealieModel):
@@ -153,20 +162,6 @@ class UserSummaryPagination(PaginationBase):
items: list[UserSummary]
-class UserFavorites(UserBase):
- favorite_recipes: list[RecipeSummary] = [] # type: ignore
- model_config = ConfigDict(from_attributes=True)
-
- @classmethod
- def loader_options(cls) -> list[LoaderOption]:
- return [
- joinedload(User.group),
- selectinload(User.favorite_recipes).joinedload(RecipeModel.recipe_category),
- selectinload(User.favorite_recipes).joinedload(RecipeModel.tags),
- selectinload(User.favorite_recipes).joinedload(RecipeModel.tools),
- ]
-
-
class PrivateUser(UserOut):
password: str
group_id: UUID4
@@ -198,7 +193,7 @@ def directory(self) -> Path:
@classmethod
def loader_options(cls) -> list[LoaderOption]:
- return [joinedload(User.group), selectinload(User.favorite_recipes), joinedload(User.tokens)]
+ return [joinedload(User.group), joinedload(User.tokens)]
class UpdateGroup(GroupBase):
@@ -244,7 +239,6 @@ def loader_options(cls) -> list[LoaderOption]:
joinedload(Group.webhooks),
joinedload(Group.preferences),
selectinload(Group.users).joinedload(User.group),
- selectinload(Group.users).joinedload(User.favorite_recipes),
selectinload(Group.users).joinedload(User.tokens),
]
diff --git a/mealie/schema/user/user_passwords.py b/mealie/schema/user/user_passwords.py
index 3e10945eeb1..eb407b7d929 100644
--- a/mealie/schema/user/user_passwords.py
+++ b/mealie/schema/user/user_passwords.py
@@ -39,6 +39,5 @@ class PrivatePasswordResetToken(SavePasswordResetToken):
def loader_options(cls) -> list[LoaderOption]:
return [
selectinload(PasswordResetModel.user).joinedload(User.group),
- selectinload(PasswordResetModel.user).joinedload(User.favorite_recipes),
selectinload(PasswordResetModel.user).joinedload(User.tokens),
]
diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py
index 45a699eded1..7fde8a2b170 100644
--- a/mealie/services/recipe/recipe_service.py
+++ b/mealie/services/recipe/recipe_service.py
@@ -20,7 +20,7 @@
from mealie.schema.recipe.recipe_step import RecipeStep
from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType
from mealie.schema.recipe.request_helpers import RecipeDuplicate
-from mealie.schema.user.user import GroupInDB, PrivateUser
+from mealie.schema.user.user import GroupInDB, PrivateUser, UserRatingCreate
from mealie.services._base_service import BaseService
from mealie.services.recipe.recipe_data_service import RecipeDataService
@@ -145,8 +145,20 @@ def create_one(self, create_data: Recipe | CreateRecipe) -> Recipe:
else:
data.settings = RecipeSettings()
+ rating_input = data.rating
new_recipe = self.repos.recipes.create(data)
+ # convert rating into user rating
+ if rating_input:
+ self.repos.user_ratings.create(
+ UserRatingCreate(
+ user_id=self.user.id,
+ recipe_id=new_recipe.id,
+ rating=rating_input,
+ is_favorite=False,
+ )
+ )
+
# create first timeline entry
timeline_event_data = RecipeTimelineEventCreate(
user_id=new_recipe.user_id,
diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_favorites.py b/tests/integration_tests/user_recipe_tests/test_recipe_favorites.py
deleted file mode 100644
index 246a05846e8..00000000000
--- a/tests/integration_tests/user_recipe_tests/test_recipe_favorites.py
+++ /dev/null
@@ -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
diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_ratings.py b/tests/integration_tests/user_recipe_tests/test_recipe_ratings.py
new file mode 100644
index 00000000000..3d9e272e1a2
--- /dev/null
+++ b/tests/integration_tests/user_recipe_tests/test_recipe_ratings.py
@@ -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
diff --git a/tests/unit_tests/repository_tests/test_recipe_repository.py b/tests/unit_tests/repository_tests/test_recipe_repository.py
index 4ebb502e11a..552c560d043 100644
--- a/tests/unit_tests/repository_tests/test_recipe_repository.py
+++ b/tests/unit_tests/repository_tests/test_recipe_repository.py
@@ -1,5 +1,6 @@
from datetime import datetime
from typing import cast
+from uuid import UUID
import pytest
@@ -10,7 +11,7 @@
from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagSave
from mealie.schema.recipe.recipe_tool import RecipeToolSave
from mealie.schema.response import OrderDirection, PaginationQuery
-from mealie.schema.user.user import GroupBase
+from mealie.schema.user.user import GroupBase, UserRatingCreate
from tests.utils.factories import random_email, random_string
from tests.utils.fixture_schemas import TestUser
@@ -658,3 +659,126 @@ def test_random_order_recipe_search(
pagination.pagination_seed = str(datetime.now())
random_ordered.append(repo.page_all(pagination, search="soup").items)
assert not all(i == random_ordered[0] for i in random_ordered)
+
+
+def test_order_by_rating(database: AllRepositories, user_tuple: tuple[TestUser, TestUser]):
+ user_1, user_2 = user_tuple
+ repo = database.recipes.by_group(UUID(user_1.group_id))
+
+ recipes: list[Recipe] = []
+ for i in range(3):
+ slug = f"recipe-{i+1}-{random_string(5)}"
+ recipes.append(
+ database.recipes.create(
+ Recipe(
+ user_id=user_1.user_id,
+ group_id=user_1.group_id,
+ name=slug,
+ slug=slug,
+ )
+ )
+ )
+
+ # set the rating for user_1 and confirm both users see the same ordering
+ recipe_1, recipe_2, recipe_3 = recipes
+ database.user_ratings.create(
+ UserRatingCreate(
+ user_id=user_1.user_id,
+ recipe_id=recipe_1.id,
+ rating=5,
+ )
+ )
+ database.user_ratings.create(
+ UserRatingCreate(
+ user_id=user_1.user_id,
+ recipe_id=recipe_2.id,
+ rating=4,
+ )
+ )
+ database.user_ratings.create(
+ UserRatingCreate(
+ user_id=user_1.user_id,
+ recipe_id=recipe_3.id,
+ rating=3,
+ )
+ )
+
+ pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.desc)
+ data_1 = repo.by_user(user_1.user_id).page_all(pq).items
+ data_2 = repo.by_user(user_2.user_id).page_all(pq).items
+ for data in [data_1, data_2]:
+ assert len(data) == 3
+ assert data[0].slug == recipe_1.slug # global and user rating == 5
+ assert data[1].slug == recipe_2.slug # global and user rating == 4
+ assert data[2].slug == recipe_3.slug # global and user rating == 3
+
+ pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.asc)
+ data_1 = repo.by_user(user_1.user_id).page_all(pq).items
+ data_2 = repo.by_user(user_2.user_id).page_all(pq).items
+ for data in [data_1, data_2]:
+ assert len(data) == 3
+ assert data[0].slug == recipe_3.slug # global and user rating == 3
+ assert data[1].slug == recipe_2.slug # global and user rating == 4
+ assert data[2].slug == recipe_1.slug # global and user rating == 5
+
+ # set rating for one recipe for user_2 and confirm user_2 sees the correct order and user_1's order is unchanged
+ database.user_ratings.create(
+ UserRatingCreate(
+ user_id=user_2.user_id,
+ recipe_id=recipe_1.id,
+ rating=3.5,
+ )
+ )
+
+ pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.desc)
+ data_1 = repo.by_user(user_1.user_id).page_all(pq).items
+ data_2 = repo.by_user(user_2.user_id).page_all(pq).items
+
+ assert len(data_1) == 3
+ assert data_1[0].slug == recipe_1.slug # user rating == 5
+ assert data_1[1].slug == recipe_2.slug # user rating == 4
+ assert data_1[2].slug == recipe_3.slug # user rating == 3
+
+ assert len(data_2) == 3
+ assert data_2[0].slug == recipe_2.slug # global rating == 4
+ assert data_2[1].slug == recipe_1.slug # user rating == 3.5
+ assert data_2[2].slug == recipe_3.slug # user rating == 3
+
+ pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.asc)
+ data_1 = repo.by_user(user_1.user_id).page_all(pq).items
+ data_2 = repo.by_user(user_2.user_id).page_all(pq).items
+
+ assert len(data_1) == 3
+ assert data_1[0].slug == recipe_3.slug # global and user rating == 3
+ assert data_1[1].slug == recipe_2.slug # global and user rating == 4
+ assert data_1[2].slug == recipe_1.slug # global and user rating == 5
+
+ assert len(data_2) == 3
+ assert data_2[0].slug == recipe_3.slug # user rating == 3
+ assert data_2[1].slug == recipe_1.slug # user rating == 3.5
+ assert data_2[2].slug == recipe_2.slug # global rating == 4
+
+ # verify public users see only global ratings
+ database.user_ratings.create(
+ UserRatingCreate(
+ user_id=user_2.user_id,
+ recipe_id=recipe_2.id,
+ rating=1,
+ )
+ )
+
+ pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.desc)
+ data = database.recipes.by_group(UUID(user_1.group_id)).page_all(pq).items
+
+ assert len(data) == 3
+ assert data[0].slug == recipe_1.slug # global rating == 4.25 (avg of 5 and 3.5)
+ assert data[1].slug == recipe_3.slug # global rating == 3
+ assert data[2].slug == recipe_2.slug # global rating == 2.5 (avg of 4 and 1)
+
+ pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.asc)
+ data = database.recipes.by_group(UUID(user_1.group_id)).page_all(pq).items
+
+ assert len(data) == 3
+ assert data[0].slug == recipe_2.slug # global rating == 2.5 (avg of 4 and 1)
+ assert data[1].slug == recipe_3.slug # global rating == 3
+ assert data[2].slug == recipe_1.slug # global rating == 4.25 (avg of 5 and 3.5)
diff --git a/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py b/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py
index e20cfaf2694..f072d58793d 100644
--- a/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py
+++ b/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py
@@ -1,4 +1,5 @@
import filecmp
+import statistics
from pathlib import Path
from typing import Any, cast
@@ -8,11 +9,14 @@
import tests.data as test_data
from mealie.core.config import get_app_settings
from mealie.db.db_setup import session_context
+from mealie.db.models._model_utils import GUID
from mealie.db.models.group import Group
from mealie.db.models.group.shopping_list import ShoppingList
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.db.models.recipe.recipe import RecipeModel
+from mealie.db.models.users.user_to_recipe import UserToRecipe
+from mealie.db.models.users.users import User
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
from mealie.services.backups_v2.backup_file import BackupFile
from mealie.services.backups_v2.backup_v2 import BackupV2
@@ -155,5 +159,18 @@ def test_database_restore_data(backup_path: Path):
assert unit.name_normalized
if unit.abbreviation:
assert unit.abbreviation_normalized
+
+ # 2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_ratings
+ users_by_group_id: dict[GUID, list[User]] = {}
+ for recipe in recipes:
+ users = users_by_group_id.get(recipe.group_id)
+ if users is None:
+ users = session.query(User).filter(User.group_id == recipe.group_id).all()
+ users_by_group_id[recipe.group_id] = users
+
+ user_to_recipes = session.query(UserToRecipe).filter(UserToRecipe.recipe_id == recipe.id).all()
+ user_ratings = [x.rating for x in user_to_recipes if x.rating]
+ assert recipe.rating == (statistics.mean(user_ratings) if user_ratings else None)
+
finally:
backup_v2.restore(original_data_backup)
diff --git a/tests/utils/api_routes/__init__.py b/tests/utils/api_routes/__init__.py
index 3b1eb13d475..88fe583ab99 100644
--- a/tests/utils/api_routes/__init__.py
+++ b/tests/utils/api_routes/__init__.py
@@ -181,8 +181,12 @@
"""`/api/users/reset-password`"""
users_self = "/api/users/self"
"""`/api/users/self`"""
+users_self_favorites = "/api/users/self/favorites"
+"""`/api/users/self/favorites`"""
users_self_group = "/api/users/self/group"
"""`/api/users/self/group`"""
+users_self_ratings = "/api/users/self/ratings"
+"""`/api/users/self/ratings`"""
utils_download = "/api/utils/download"
"""`/api/utils/download`"""
validators_group = "/api/validators/group"
@@ -490,6 +494,21 @@ def users_id_image(id):
return f"{prefix}/users/{id}/image"
+def users_id_ratings(id):
+ """`/api/users/{id}/ratings`"""
+ return f"{prefix}/users/{id}/ratings"
+
+
+def users_id_ratings_slug(id, slug):
+ """`/api/users/{id}/ratings/{slug}`"""
+ return f"{prefix}/users/{id}/ratings/{slug}"
+
+
def users_item_id(item_id):
"""`/api/users/{item_id}`"""
return f"{prefix}/users/{item_id}"
+
+
+def users_self_ratings_recipe_id(recipe_id):
+ """`/api/users/self/ratings/{recipe_id}`"""
+ return f"{prefix}/users/self/ratings/{recipe_id}"