feat: User-specific Recipe Ratings (#3345)

This commit is contained in:
Michael Genson 2024-04-11 21:28:43 -05:00 committed by GitHub
parent 8ab09cf03b
commit 2a541f081a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 1497 additions and 443 deletions

View File

@ -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 ###

View File

@ -19,7 +19,7 @@
<script lang="ts">
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
import { RecipeTag, RecipeCategory } from "~/lib/api/types/group";
import { RecipeTag, RecipeCategory } from "~/lib/api/types/recipe";
export default defineComponent({
components: {

View File

@ -21,7 +21,7 @@
<v-spacer></v-spacer>
<div v-if="!open" class="custom-btn-group ma-1">
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :slug="recipe.slug" show-always />
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :recipe-id="recipe.id" show-always />
<RecipeTimelineBadge v-if="loggedIn" button-style :slug="recipe.slug" :recipe-name="recipe.name" />
<div v-if="loggedIn">
<v-tooltip v-if="!locked" bottom color="info">

View File

@ -35,9 +35,9 @@
<slot name="actions">
<v-card-actions v-if="showRecipeContent" class="px-1">
<RecipeFavoriteBadge v-if="isOwnGroup" class="absolute" :slug="slug" show-always />
<RecipeFavoriteBadge v-if="isOwnGroup" class="absolute" :recipe-id="recipeId" show-always />
<RecipeRating class="pb-1" :value="rating" :name="name" :slug="slug" :small="true" />
<RecipeRating class="pb-1" :value="rating" :recipe-id="recipeId" :slug="slug" :small="true" />
<v-spacer></v-spacer>
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" />
@ -97,6 +97,10 @@ export default defineComponent({
required: false,
default: 0,
},
ratingColor: {
type: String,
default: "secondary",
},
image: {
type: String,
required: false,

View File

@ -38,17 +38,14 @@
</v-list-item-subtitle>
<div class="d-flex flex-wrap justify-end align-center">
<slot name="actions">
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :slug="slug" show-always />
<v-rating
v-if="showRecipeContent"
color="secondary"
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :recipe-id="recipeId" show-always />
<RecipeRating
:class="isOwnGroup ? 'ml-auto' : 'ml-auto pb-2'"
background-color="secondary lighten-3"
dense
length="5"
size="15"
:value="rating"
></v-rating>
:recipe-id="recipeId"
:slug="slug"
:small="true"
/>
<v-spacer></v-spacer>
<!-- If we're not logged-in, no items display, so we hide this menu -->
@ -85,12 +82,14 @@ import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composi
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeCardImage from "./RecipeCardImage.vue";
import RecipeRating from "./RecipeRating.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state";
export default defineComponent({
components: {
RecipeFavoriteBadge,
RecipeContextMenu,
RecipeRating,
RecipeCardImage,
},
props: {

View File

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

View File

@ -22,11 +22,12 @@
<script lang="ts">
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import { useUserSelfRatings } from "~/composables/use-users";
import { useUserApi } from "~/composables/api";
import { UserOut } from "~/lib/api/types/user";
export default defineComponent({
props: {
slug: {
recipeId: {
type: String,
default: "",
},
@ -42,19 +43,23 @@ export default defineComponent({
setup(props) {
const api = useUserApi();
const { $auth } = useContext();
const { userRatings, refreshUserRatings } = useUserSelfRatings();
// TODO Setup the correct type for $auth.user
// See https://github.com/nuxt-community/auth-module/issues/1097
const user = computed(() => $auth.user as unknown as UserOut);
const isFavorite = computed(() => user.value?.favoriteRecipes?.includes(props.slug));
const isFavorite = computed(() => {
const rating = userRatings.value.find((r) => r.recipeId === props.recipeId);
return rating?.isFavorite || false;
});
async function toggleFavorite() {
if (!isFavorite.value) {
await api.users.addFavorite(user.value?.id, props.slug);
await api.users.addFavorite(user.value?.id, props.recipeId);
} else {
await api.users.removeFavorite(user.value?.id, props.slug);
await api.users.removeFavorite(user.value?.id, props.recipeId);
}
$auth.fetchUser();
await refreshUserRatings();
}
return { isFavorite, toggleFavorite };

View File

@ -46,7 +46,7 @@
<script lang="ts">
import { defineComponent, ref, useContext, computed, onMounted } from "@nuxtjs/composition-api";
import RecipeOrganizerDialog from "./RecipeOrganizerDialog.vue";
import { RecipeCategory, RecipeTag } from "~/lib/api/types/user";
import { RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
import { RecipeTool } from "~/lib/api/types/admin";
import { useTagStore } from "~/composables/store/use-tag-store";
import { useCategoryStore, useToolStore } from "~/composables/store";

View File

@ -5,7 +5,7 @@
<v-card-text>
<v-card-title class="headline pa-0 flex-column align-center">
{{ recipe.name }}
<RecipeRating :key="recipe.slug" v-model="recipe.rating" :name="recipe.name" :slug="recipe.slug" />
<RecipeRating :key="recipe.slug" :value="recipe.rating" :recipe-id="recipe.id" :slug="recipe.slug" />
</v-card-title>
<v-divider class="my-2"></v-divider>
<SafeMarkdown :source="recipe.description" />

View File

@ -20,7 +20,7 @@
v-if="landscape && $vuetify.breakpoint.smAndUp"
:key="recipe.slug"
v-model="recipe.rating"
:name="recipe.name"
:recipe-id="recipe.id"
:slug="recipe.slug"
/>
</div>

View File

@ -24,7 +24,7 @@
v-if="$vuetify.breakpoint.smAndDown"
:key="recipe.slug"
v-model="recipe.rating"
:name="recipe.name"
:recipe-id="recipe.id"
:slug="recipe.slug"
/>
</div>

View File

@ -1,34 +1,35 @@
<template>
<div @click.prevent>
<v-rating
v-model="rating"
:readonly="!isOwnGroup"
color="secondary"
background-color="secondary lighten-3"
length="5"
:dense="small ? true : undefined"
:size="small ? 15 : undefined"
hover
:value="value"
clearable
@input="updateRating"
@click="updateRating"
></v-rating>
<v-hover v-slot="{ hover }">
<v-rating
:value="rating.ratingValue"
:half-increments="(!hover) || (!isOwnGroup)"
:readonly="!isOwnGroup"
:color="hover ? attrs.hoverColor : attrs.color"
:background-color="attrs.backgroundColor"
length="5"
:dense="small ? true : undefined"
:size="small ? 15 : undefined"
hover
clearable
@input="updateRating"
@click="updateRating"
/>
</v-hover>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api";
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useUserApi } from "~/composables/api";
import { useUserSelfRatings } from "~/composables/use-users";
export default defineComponent({
props: {
emitOnly: {
type: Boolean,
default: false,
},
// TODO Remove name prop?
name: {
recipeId: {
type: String,
default: "",
},
@ -44,26 +45,79 @@ export default defineComponent({
type: Boolean,
default: false,
},
preferGroupRating: {
type: Boolean,
default: false,
},
},
setup(props, context) {
const { $auth } = useContext();
const { isOwnGroup } = useLoggedInState();
const { userRatings, setRating, ready: ratingsLoaded } = useUserSelfRatings();
const hideGroupRating = ref(false);
const rating = ref(props.value);
type Rating = {
ratingValue: number | undefined;
hasUserRating: boolean | undefined
};
const api = useUserApi();
function updateRating(val: number | null) {
if (val === 0) {
val = null;
// prefer user rating over group rating
const rating = computed<Rating>(() => {
if (!ratingsLoaded.value) {
return { ratingValue: undefined, hasUserRating: undefined };
}
if (!($auth.user?.id) || props.preferGroupRating) {
return { ratingValue: props.value, hasUserRating: false };
}
const userRating = userRatings.value.find((r) => r.recipeId === props.recipeId);
return {
ratingValue: userRating?.rating || (hideGroupRating.value ? 0 : props.value),
hasUserRating: !!userRating?.rating
};
});
// if a user unsets their rating, we don't want to fall back to the group rating since it's out of sync
watch(
() => rating.value.hasUserRating,
() => {
if (rating.value.hasUserRating && !props.preferGroupRating) {
hideGroupRating.value = true;
}
},
)
const attrs = computed(() => {
return isOwnGroup.value ? {
// Logged-in user
color: rating.value.hasUserRating ? "secondary" : "grey darken-1",
hoverColor: "secondary",
backgroundColor: "secondary lighten-3",
} : {
// Anonymous user
color: "secondary",
hoverColor: "secondary",
backgroundColor: "secondary lighten-3",
};
})
function updateRating(val: number | null) {
if (!isOwnGroup.value) {
return;
}
if (!props.emitOnly) {
api.recipes.patchOne(props.slug, {
rating: val,
});
setRating(props.slug, val || 0, null);
}
context.emit("input", val);
}
return { isOwnGroup, rating, updateRating };
return {
attrs,
isOwnGroup,
rating,
updateRating,
};
},
});
</script>

View File

@ -2,7 +2,7 @@ import { reactive, ref, useAsync } from "@nuxtjs/composition-api";
import { useAsyncKey } from "../use-utils";
import { useUserApi } from "~/composables/api";
import { VForm } from "~/types/vuetify";
import { RecipeTool } from "~/lib/api/types/user";
import { RecipeTool } from "~/lib/api/types/recipe";
export const useTools = function (eager = true) {
const workingToolData = reactive<RecipeTool>({

View File

@ -2,7 +2,7 @@ import { reactive, ref, Ref } from "@nuxtjs/composition-api";
import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory";
import { usePublicExploreApi } from "../api/api-client";
import { useUserApi } from "~/composables/api";
import { RecipeCategory } from "~/lib/api/types/admin";
import { RecipeCategory } from "~/lib/api/types/recipe";
const categoryStore: Ref<RecipeCategory[]> = ref([]);
const publicStoreLoading = ref(false);

View File

@ -1,2 +1,3 @@
export { useUserForm } from "./user-form";
export { useUserRegistrationForm } from "./user-registration-form";
export { useUserSelfRatings } from "./user-ratings";

View 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,
}
}

View File

@ -5,10 +5,11 @@
/* Do not modify it by hand - just update the pydantic models and then re-run the script
*/
export type WebhookType = "mealplan";
export type AuthMethod = "Mealie" | "LDAP" | "OIDC";
export interface ChangePassword {
currentPassword: string;
currentPassword?: string;
newPassword: string;
}
export interface CreateToken {
@ -30,6 +31,11 @@ export interface CreateUserRegistration {
seedData?: boolean;
locale?: string;
}
export interface CredentialsRequest {
username: string;
password: string;
remember_me?: boolean;
}
export interface DeleteTokenResponse {
tokenDelete: string;
}
@ -44,7 +50,7 @@ export interface GroupInDB {
id: string;
slug: string;
categories?: CategoryBase[];
webhooks?: unknown[];
webhooks?: ReadWebhook[];
users?: UserOut[];
preferences?: ReadGroupPreferences;
}
@ -60,7 +66,17 @@ export interface CategoryBase {
id: string;
slug: string;
}
export interface ReadWebhook {
enabled?: boolean;
name?: string;
url?: string;
webhookType?: WebhookType & string;
scheduledTime: string;
groupId: string;
id: string;
}
export interface UserOut {
id: string;
username?: string;
fullName?: string;
email: string;
@ -68,11 +84,9 @@ export interface UserOut {
admin?: boolean;
group: string;
advanced?: boolean;
favoriteRecipes?: string[];
canInvite?: boolean;
canManage?: boolean;
canOrganize?: boolean;
id: string;
groupId: string;
groupSlug: string;
tokens?: LongLiveTokenOut[];
@ -109,6 +123,7 @@ export interface LongLiveTokenInDB {
user: PrivateUser;
}
export interface PrivateUser {
id: string;
username?: string;
fullName?: string;
email: string;
@ -116,11 +131,9 @@ export interface PrivateUser {
admin?: boolean;
group: string;
advanced?: boolean;
favoriteRecipes?: string[];
canInvite?: boolean;
canManage?: boolean;
canOrganize?: boolean;
id: string;
groupId: string;
groupSlug: string;
tokens?: LongLiveTokenOut[];
@ -129,6 +142,9 @@ export interface PrivateUser {
loginAttemps?: number;
lockedAt?: string;
}
export interface OIDCRequest {
id_token: string;
}
export interface PasswordResetToken {
token: string;
}
@ -163,9 +179,17 @@ export interface UpdateGroup {
id: string;
slug: string;
categories?: CategoryBase[];
webhooks?: unknown[];
webhooks?: CreateWebhook[];
}
export interface CreateWebhook {
enabled?: boolean;
name?: string;
url?: string;
webhookType?: WebhookType & string;
scheduledTime: string;
}
export interface UserBase {
id?: string;
username?: string;
fullName?: string;
email: string;
@ -173,65 +197,12 @@ export interface UserBase {
admin?: boolean;
group?: string;
advanced?: boolean;
favoriteRecipes?: string[];
canInvite?: boolean;
canManage?: boolean;
canOrganize?: boolean;
}
export interface UserFavorites {
username?: string;
fullName?: string;
email: string;
authMethod?: AuthMethod & string;
admin?: boolean;
group?: string;
advanced?: boolean;
favoriteRecipes?: RecipeSummary[];
canInvite?: boolean;
canManage?: boolean;
canOrganize?: boolean;
}
export interface RecipeSummary {
id?: string;
userId?: string;
groupId?: string;
name?: string;
slug?: string;
image?: unknown;
recipeYield?: string;
totalTime?: string;
prepTime?: string;
cookTime?: string;
performTime?: string;
description?: string;
recipeCategory?: RecipeCategory[];
tags?: RecipeTag[];
tools?: RecipeTool[];
rating?: number;
orgURL?: string;
dateAdded?: string;
dateUpdated?: string;
createdAt?: string;
updateAt?: string;
lastMade?: string;
}
export interface RecipeCategory {
id?: string;
name: string;
slug: string;
}
export interface RecipeTag {
id?: string;
name: string;
slug: string;
}
export interface RecipeTool {
id: string;
name: string;
slug: string;
onHand?: boolean;
}
export interface UserIn {
id?: string;
username?: string;
fullName?: string;
email: string;
@ -239,15 +210,32 @@ export interface UserIn {
admin?: boolean;
group?: string;
advanced?: boolean;
favoriteRecipes?: string[];
canInvite?: boolean;
canManage?: boolean;
canOrganize?: boolean;
password: string;
}
export interface UserRatingCreate {
recipeId: string;
rating?: number;
isFavorite?: boolean;
userId: string;
}
export interface UserRatingOut {
recipeId: string;
rating?: number;
isFavorite?: boolean;
userId: string;
id: string;
}
export interface UserRatingSummary {
recipeId: string;
rating?: number;
isFavorite?: boolean;
}
export interface UserSummary {
id: string;
fullName?: string;
fullName: string;
}
export interface ValidateResetToken {
token: string;

View File

@ -9,17 +9,27 @@ import {
LongLiveTokenOut,
ResetPassword,
UserBase,
UserFavorites,
UserIn,
UserOut,
UserRatingOut,
UserRatingSummary,
UserSummary,
} from "~/lib/api/types/user";
export interface UserRatingsSummaries {
ratings: UserRatingSummary[];
}
export interface UserRatingsOut {
ratings: UserRatingOut[];
}
const prefix = "/api";
const routes = {
groupUsers: `${prefix}/users/group-users`,
usersSelf: `${prefix}/users/self`,
ratingsSelf: `${prefix}/users/self/ratings`,
groupsSelf: `${prefix}/users/self/group`,
passwordReset: `${prefix}/users/reset-password`,
passwordChange: `${prefix}/users/password`,
@ -30,6 +40,10 @@ const routes = {
usersId: (id: string) => `${prefix}/users/${id}`,
usersIdFavorites: (id: string) => `${prefix}/users/${id}/favorites`,
usersIdFavoritesSlug: (id: string, slug: string) => `${prefix}/users/${id}/favorites/${slug}`,
usersIdRatings: (id: string) => `${prefix}/users/${id}/ratings`,
usersIdRatingsSlug: (id: string, slug: string) => `${prefix}/users/${id}/ratings/${slug}`,
usersSelfFavoritesId: (id: string) => `${prefix}/users/self/favorites/${id}`,
usersSelfRatingsId: (id: string) => `${prefix}/users/self/ratings/${id}`,
usersApiTokens: `${prefix}/users/api-tokens`,
usersApiTokensTokenId: (token_id: string | number) => `${prefix}/users/api-tokens/${token_id}`,
@ -56,7 +70,23 @@ export class UserApi extends BaseCRUDAPI<UserIn, UserOut, UserBase> {
}
async getFavorites(id: string) {
return await this.requests.get<UserFavorites>(routes.usersIdFavorites(id));
return await this.requests.get<UserRatingsOut>(routes.usersIdFavorites(id));
}
async getSelfFavorites() {
return await this.requests.get<UserRatingsSummaries>(routes.ratingsSelf);
}
async getRatings(id: string) {
return await this.requests.get<UserRatingsOut>(routes.usersIdRatings(id));
}
async setRating(id: string, slug: string, rating: number | null, isFavorite: boolean | null) {
return await this.requests.post(routes.usersIdRatingsSlug(id, slug), { rating, isFavorite });
}
async getSelfRatings() {
return await this.requests.get<UserRatingsSummaries>(routes.ratingsSelf);
}
async changePassword(changePassword: ChangePassword) {

View File

@ -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() {

View File

@ -1,7 +1,11 @@
<template>
<v-container>
<RecipeCardSection v-if="user && isOwnGroup" :icon="$globals.icons.heart" :title="$tc('user.user-favorites')" :recipes="user.favoriteRecipes">
</RecipeCardSection>
<RecipeCardSection
v-if="recipes && isOwnGroup"
:icon="$globals.icons.heart"
:title="$tc('user.user-favorites')"
:recipes="recipes"
/>
</v-container>
</template>
@ -21,14 +25,13 @@ export default defineComponent({
const { isOwnGroup } = useLoggedInState();
const userId = route.value.params.id;
const user = useAsync(async () => {
const { data } = await api.users.getFavorites(userId);
return data;
const recipes = useAsync(async () => {
const { data } = await api.recipes.getAll(1, -1, { queryFilter: `favoritedBy.id = "${userId}"` });
return data?.items || null;
}, useAsyncKey());
return {
user,
recipes,
isOwnGroup,
};
},

View File

@ -7,12 +7,14 @@ from pydantic import ConfigDict
from sqlalchemy import event
from sqlalchemy.ext.orderinglist import ordering_list
from sqlalchemy.orm import Mapped, mapped_column, validates
from sqlalchemy.orm.attributes import get_history
from sqlalchemy.orm.session import object_session
from mealie.db.models._model_utils.guid import GUID
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
from ..users.user_to_favorite import users_to_favorites
from ..users.user_to_recipe import UserToRecipe
from .api_extras import ApiExtras, api_extras
from .assets import RecipeAsset
from .category import recipes_to_categories
@ -49,12 +51,20 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
user_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("users.id", use_alter=True), index=True)
user: Mapped["User"] = orm.relationship("User", uselist=False, foreign_keys=[user_id])
meal_entries: Mapped[list["GroupMealPlan"]] = orm.relationship(
"GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan"
rating: Mapped[float | None] = mapped_column(sa.Float, index=True, nullable=True)
rated_by: Mapped[list["User"]] = orm.relationship(
"User", secondary=UserToRecipe.__tablename__, back_populates="rated_recipes"
)
favorited_by: Mapped[list["User"]] = orm.relationship(
"User",
secondary=UserToRecipe.__tablename__,
primaryjoin="and_(RecipeModel.id==UserToRecipe.recipe_id, UserToRecipe.is_favorite==True)",
back_populates="favorite_recipes",
viewonly=True,
)
favorited_by: Mapped[list["User"]] = orm.relationship(
"User", secondary=users_to_favorites, back_populates="favorite_recipes"
meal_entries: Mapped[list["GroupMealPlan"]] = orm.relationship(
"GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan"
)
# General Recipe Properties
@ -110,7 +120,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
)
tags: Mapped[list["Tag"]] = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes")
notes: Mapped[list[Note]] = orm.relationship("Note", cascade="all, delete-orphan")
rating: Mapped[int | None] = mapped_column(sa.Integer)
org_url: Mapped[str | None] = mapped_column(sa.String)
extras: Mapped[list[ApiExtras]] = orm.relationship("ApiExtras", cascade="all, delete-orphan")
is_ocr_recipe: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
@ -246,3 +255,23 @@ def receive_description(target: RecipeModel, value: str, oldvalue, initiator):
target.description_normalized = RecipeModel.normalize(value)
else:
target.description_normalized = None
@event.listens_for(RecipeModel, "before_update")
def calculate_rating(mapper, connection, target: RecipeModel):
session = object_session(target)
if not session:
return
if session.is_modified(target, "rating"):
history = get_history(target, "rating")
old_value = history.deleted[0] if history.deleted else None
new_value = history.added[0] if history.added else None
if old_value == new_value:
return
target.rating = (
session.query(sa.func.avg(UserToRecipe.rating))
.filter(UserToRecipe.recipe_id == target.id, UserToRecipe.rating is not None, UserToRecipe.rating > 0)
.scalar()
)

View File

@ -1,3 +1,3 @@
from .password_reset import *
from .user_to_favorite import *
from .user_to_recipe import *
from .users import *

View File

@ -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"),
)

View 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()

View File

@ -12,7 +12,7 @@ from mealie.db.models._model_utils.guid import GUID
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
from .user_to_favorite import users_to_favorites
from .user_to_recipe import UserToRecipe
if TYPE_CHECKING:
from ..group import Group
@ -49,7 +49,7 @@ class User(SqlAlchemyBase, BaseMixins):
username: Mapped[str | None] = mapped_column(String, index=True, unique=True)
email: Mapped[str | None] = mapped_column(String, unique=True, index=True)
password: Mapped[str | None] = mapped_column(String)
auth_method: Mapped[Enum(AuthMethod)] = mapped_column(Enum(AuthMethod), default=AuthMethod.MEALIE)
auth_method: Mapped[Enum[AuthMethod]] = mapped_column(Enum(AuthMethod), default=AuthMethod.MEALIE)
admin: Mapped[bool | None] = mapped_column(Boolean, default=False)
advanced: Mapped[bool | None] = mapped_column(Boolean, default=False)
@ -84,8 +84,15 @@ class User(SqlAlchemyBase, BaseMixins):
"GroupMealPlan", order_by="GroupMealPlan.date", **sp_args
)
shopping_lists: Mapped[Optional["ShoppingList"]] = orm.relationship("ShoppingList", **sp_args)
rated_recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=UserToRecipe.__tablename__, back_populates="rated_by"
)
favorite_recipes: Mapped[list["RecipeModel"]] = orm.relationship(
"RecipeModel", secondary=users_to_favorites, back_populates="favorited_by"
"RecipeModel",
secondary=UserToRecipe.__tablename__,
primaryjoin="and_(User.id==UserToRecipe.user_id, UserToRecipe.is_favorite==True)",
back_populates="favorited_by",
viewonly=True,
)
model_config = ConfigDict(
exclude={
@ -112,7 +119,7 @@ class User(SqlAlchemyBase, BaseMixins):
self.group = Group.get_by_name(session, group)
self.favorite_recipes = []
self.rated_recipes = []
self.password = password

View File

@ -31,6 +31,7 @@ from mealie.db.models.recipe.tool import Tool
from mealie.db.models.server.task import ServerTaskModel
from mealie.db.models.users import LongLiveToken, User
from mealie.db.models.users.password_reset import PasswordResetModel
from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.repos.repository_foods import RepositoryFood
from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
from mealie.repos.repository_units import RepositoryUnit
@ -58,6 +59,7 @@ from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventOut
from mealie.schema.reports.reports import ReportEntryOut, ReportOut
from mealie.schema.server import ServerTask
from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser
from mealie.schema.user.user import UserRatingOut
from mealie.schema.user.user_passwords import PrivatePasswordResetToken
from .repository_generic import RepositoryGeneric
@ -65,7 +67,7 @@ from .repository_group import RepositoryGroup
from .repository_meals import RepositoryMeals
from .repository_recipes import RepositoryRecipes
from .repository_shopping_list import RepositoryShoppingList
from .repository_users import RepositoryUsers
from .repository_users import RepositoryUserRatings, RepositoryUsers
PK_ID = "id"
PK_SLUG = "slug"
@ -143,6 +145,10 @@ class AllRepositories:
def users(self) -> RepositoryUsers:
return RepositoryUsers(self.session, PK_ID, User, PrivateUser)
@cached_property
def user_ratings(self) -> RepositoryUserRatings:
return RepositoryUserRatings(self.session, PK_ID, UserToRecipe, UserRatingOut)
@cached_property
def api_tokens(self) -> RepositoryGeneric[LongLiveTokenInDB, LongLiveToken]:
return RepositoryGeneric(self.session, PK_ID, LongLiveToken, LongLiveTokenInDB)

View File

@ -8,6 +8,7 @@ from typing import Any, Generic, TypeVar
from fastapi import HTTPException
from pydantic import UUID4, BaseModel
from sqlalchemy import Select, case, delete, func, nulls_first, nulls_last, select
from sqlalchemy.orm import InstrumentedAttribute
from sqlalchemy.orm.session import Session
from sqlalchemy.sql import sqltypes
@ -67,9 +68,6 @@ class RepositoryGeneric(Generic[Schema, Model]):
def _filter_builder(self, **kwargs) -> dict[str, Any]:
dct = {}
if self.user_id:
dct["user_id"] = self.user_id
if self.group_id:
dct["group_id"] = self.group_id
@ -287,7 +285,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
pagination is a method to interact with the filtered database table and return a paginated result
using the PaginationBase that provides several data points that are needed to manage pagination
on the client side. This method does utilize the _filter_build method to ensure that the results
are filtered by the user and group id when applicable.
are filtered by the group id when applicable.
NOTE: When you provide an override you'll need to manually type the result of this method
as the override, as the type system is not able to infer the result of this method.
@ -368,6 +366,29 @@ class RepositoryGeneric(Generic[Schema, Model]):
query = self.add_order_by_to_query(query, pagination)
return query.limit(pagination.per_page).offset((pagination.page - 1) * pagination.per_page), count, total_pages
def add_order_attr_to_query(
self,
query: Select,
order_attr: InstrumentedAttribute,
order_dir: OrderDirection,
order_by_null: OrderByNullPosition | None,
) -> Select:
if order_dir is OrderDirection.asc:
order_attr = order_attr.asc()
elif order_dir is OrderDirection.desc:
order_attr = order_attr.desc()
# queries handle uppercase and lowercase differently, which is undesirable
if isinstance(order_attr.type, sqltypes.String):
order_attr = func.lower(order_attr)
if order_by_null is OrderByNullPosition.first:
order_attr = nulls_first(order_attr)
elif order_by_null is OrderByNullPosition.last:
order_attr = nulls_last(order_attr)
return query.order_by(order_attr)
def add_order_by_to_query(self, query: Select, pagination: PaginationQuery) -> Select:
if not pagination.order_by:
return query
@ -399,21 +420,9 @@ class RepositoryGeneric(Generic[Schema, Model]):
order_by, self.model, query=query
)
if order_dir is OrderDirection.asc:
order_attr = order_attr.asc()
elif order_dir is OrderDirection.desc:
order_attr = order_attr.desc()
# queries handle uppercase and lowercase differently, which is undesirable
if isinstance(order_attr.type, sqltypes.String):
order_attr = func.lower(order_attr)
if pagination.order_by_null_position is OrderByNullPosition.first:
order_attr = nulls_first(order_attr)
elif pagination.order_by_null_position is OrderByNullPosition.last:
order_attr = nulls_last(order_attr)
query = query.order_by(order_attr)
query = self.add_order_attr_to_query(
query, order_attr, order_dir, pagination.order_by_null_position
)
except ValueError as e:
raise HTTPException(

View File

@ -3,11 +3,11 @@ from collections.abc import Sequence
from random import randint
from uuid import UUID
import sqlalchemy as sa
from pydantic import UUID4
from slugify import slugify
from sqlalchemy import and_, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import InstrumentedAttribute, joinedload
from mealie.db.models.recipe.category import Category
from mealie.db.models.recipe.ingredient import RecipeIngredientModel
@ -15,11 +15,12 @@ from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.settings import RecipeSettings
from mealie.db.models.recipe.tag import Tag
from mealie.db.models.recipe.tool import Tool
from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary, RecipeTag, RecipeTool
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.response.pagination import OrderByNullPosition, OrderDirection, PaginationQuery
from ..db.models._model_base import SqlAlchemyBase
from ..schema._mealie.mealie_model import extract_uuids
@ -51,7 +52,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
if order_by:
order_attr = getattr(self.model, str(order_by))
stmt = (
select(self.model)
sa.select(self.model)
.join(RecipeSettings)
.filter(RecipeSettings.public == True) # noqa: E712
.order_by(order_attr.desc())
@ -61,7 +62,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
return [eff_schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
stmt = (
select(self.model)
sa.select(self.model)
.join(RecipeSettings)
.filter(RecipeSettings.public == True) # noqa: E712
.offset(start)
@ -121,7 +122,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
order_attr = order_attr.asc()
stmt = (
select(RecipeModel)
sa.select(RecipeModel)
.options(*args)
.filter(RecipeModel.group_id == group_id)
.order_by(order_attr)
@ -145,9 +146,54 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
ids.append(i_as_uuid)
except ValueError:
slugs.append(i)
additional_ids = self.session.execute(select(model.id).filter(model.slug.in_(slugs))).scalars().all()
additional_ids = self.session.execute(sa.select(model.id).filter(model.slug.in_(slugs))).scalars().all()
return ids + additional_ids
def add_order_attr_to_query(
self,
query: sa.Select,
order_attr: InstrumentedAttribute,
order_dir: OrderDirection,
order_by_null: OrderByNullPosition | None,
) -> sa.Select:
"""Special handling for ordering recipes by rating"""
column_name = order_attr.key
if column_name != "rating" or not self.user_id:
return super().add_order_attr_to_query(query, order_attr, order_dir, order_by_null)
# calculate the effictive rating for the user by using the user's rating if it exists,
# falling back to the recipe's rating if it doesn't
effective_rating_column_name = "_effective_rating"
query = query.add_columns(
sa.case(
(
sa.exists().where(
UserToRecipe.recipe_id == self.model.id,
UserToRecipe.user_id == self.user_id,
UserToRecipe.rating is not None,
UserToRecipe.rating > 0,
),
sa.select(UserToRecipe.rating)
.where(UserToRecipe.recipe_id == self.model.id, UserToRecipe.user_id == self.user_id)
.scalar_subquery(),
),
else_=self.model.rating,
).label(effective_rating_column_name)
)
order_attr = effective_rating_column_name
if order_dir is OrderDirection.asc:
order_attr = sa.asc(order_attr)
elif order_dir is OrderDirection.desc:
order_attr = sa.desc(order_attr)
if order_by_null is OrderByNullPosition.first:
order_attr = sa.nulls_first(order_attr)
elif order_by_null is OrderByNullPosition.last:
order_attr = sa.nulls_last(order_attr)
return query.order_by(order_attr)
def page_all( # type: ignore
self,
pagination: PaginationQuery,
@ -165,7 +211,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
) -> RecipePagination:
# Copy this, because calling methods (e.g. tests) might rely on it not getting mutated
pagination_result = pagination.model_copy()
q = select(self.model)
q = sa.select(self.model)
args = [
joinedload(RecipeModel.recipe_category),
@ -236,7 +282,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
ids = [x.id for x in categories]
stmt = (
select(RecipeModel)
sa.select(RecipeModel)
.join(RecipeModel.recipe_category)
.filter(RecipeModel.recipe_category.any(Category.id.in_(ids)))
)
@ -301,7 +347,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
require_all_tags=require_all_tags,
require_all_tools=require_all_tools,
)
stmt = select(RecipeModel).filter(*fltr)
stmt = sa.select(RecipeModel).filter(*fltr)
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
def get_random_by_categories_and_tags(
@ -318,26 +364,29 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
filters = self._build_recipe_filter(extract_uuids(categories), extract_uuids(tags)) # type: ignore
stmt = (
select(RecipeModel).filter(and_(*filters)).order_by(func.random()).limit(1) # Postgres and SQLite specific
sa.select(RecipeModel)
.filter(sa.and_(*filters))
.order_by(sa.func.random())
.limit(1) # Postgres and SQLite specific
)
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
def get_random(self, limit=1) -> list[Recipe]:
stmt = (
select(RecipeModel)
sa.select(RecipeModel)
.filter(RecipeModel.group_id == self.group_id)
.order_by(func.random()) # Postgres and SQLite specific
.order_by(sa.func.random()) # Postgres and SQLite specific
.limit(limit)
)
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
def get_by_slug(self, group_id: UUID4, slug: str, limit=1) -> Recipe | None:
stmt = select(RecipeModel).filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug)
def get_by_slug(self, group_id: UUID4, slug: str) -> Recipe | None:
stmt = sa.select(RecipeModel).filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug)
dbrecipe = self.session.execute(stmt).scalars().one_or_none()
if dbrecipe is None:
return None
return self.schema.model_validate(dbrecipe)
def all_ids(self, group_id: UUID4) -> Sequence[UUID4]:
stmt = select(RecipeModel.id).filter(RecipeModel.group_id == group_id)
stmt = sa.select(RecipeModel.id).filter(RecipeModel.group_id == group_id)
return self.session.execute(stmt).scalars().all()

View File

@ -6,7 +6,8 @@ from sqlalchemy import select
from mealie.assets import users as users_assets
from mealie.core.config import get_app_settings
from mealie.schema.user.user import PrivateUser
from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.schema.user.user import PrivateUser, UserRatingOut
from ..db.models.users import User
from .repository_generic import RepositoryGeneric
@ -72,3 +73,26 @@ class RepositoryUsers(RepositoryGeneric[PrivateUser, User]):
stmt = select(User).filter(User.locked_at != None) # noqa E711
results = self.session.execute(stmt).scalars().all()
return [self.schema.model_validate(x) for x in results]
class RepositoryUserRatings(RepositoryGeneric[UserRatingOut, UserToRecipe]):
def get_by_user(self, user_id: UUID4, favorites_only=False) -> list[UserRatingOut]:
stmt = select(UserToRecipe).filter(UserToRecipe.user_id == user_id)
if favorites_only:
stmt = stmt.filter(UserToRecipe.is_favorite)
results = self.session.execute(stmt).scalars().all()
return [self.schema.model_validate(x) for x in results]
def get_by_recipe(self, recipe_id: UUID4, favorites_only=False) -> list[UserRatingOut]:
stmt = select(UserToRecipe).filter(UserToRecipe.recipe_id == recipe_id)
if favorites_only:
stmt = stmt.filter(UserToRecipe.is_favorite)
results = self.session.execute(stmt).scalars().all()
return [self.schema.model_validate(x) for x in results]
def get_by_user_and_recipe(self, user_id: UUID4, recipe_id: UUID4) -> UserRatingOut | None:
stmt = select(UserToRecipe).filter(UserToRecipe.user_id == user_id, UserToRecipe.recipe_id == recipe_id)
result = self.session.execute(stmt).scalars().one_or_none()
return None if result is None else self.schema.model_validate(result)

View File

@ -258,7 +258,8 @@ class RecipeController(BaseRecipeController):
if cookbook_data is None:
raise HTTPException(status_code=404, detail="cookbook not found")
pagination_response = self.repo.page_all(
# we use the repo by user so we can sort favorites correctly
pagination_response = self.repo.by_user(self.user.id).page_all(
pagination=q,
cookbook=cookbook_data,
categories=categories,

View File

@ -1,6 +1,6 @@
from fastapi import APIRouter
from . import api_tokens, crud, favorites, forgot_password, images, registration
from . import api_tokens, crud, forgot_password, images, ratings, registration
# Must be used because of the way FastAPI works with nested routes
user_prefix = "/users"
@ -13,4 +13,4 @@ router.include_router(crud.admin_router)
router.include_router(forgot_password.router, prefix=user_prefix, tags=["Users: Passwords"])
router.include_router(images.router, prefix=user_prefix, tags=["Users: Images"])
router.include_router(api_tokens.router)
router.include_router(favorites.router, prefix=user_prefix, tags=["Users: Favorites"])
router.include_router(ratings.router, prefix=user_prefix, tags=["Users: Ratings"])

View File

@ -11,7 +11,14 @@ from mealie.routes.users._helpers import assert_user_change_allowed
from mealie.schema.response import ErrorResponse, SuccessResponse
from mealie.schema.response.pagination import PaginationQuery
from mealie.schema.user import ChangePassword, UserBase, UserIn, UserOut
from mealie.schema.user.user import GroupInDB, UserPagination, UserSummary, UserSummaryPagination
from mealie.schema.user.user import (
GroupInDB,
UserPagination,
UserRatings,
UserRatingSummary,
UserSummary,
UserSummaryPagination,
)
user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"])
admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"])
@ -74,6 +81,25 @@ class UserController(BaseUserController):
def get_logged_in_user(self):
return self.user
@user_router.get("/self/ratings", response_model=UserRatings[UserRatingSummary])
def get_logged_in_user_ratings(self):
return UserRatings(ratings=self.repos.user_ratings.get_by_user(self.user.id))
@user_router.get("/self/ratings/{recipe_id}", response_model=UserRatingSummary)
def get_logged_in_user_rating_for_recipe(self, recipe_id: UUID4):
user_rating = self.repos.user_ratings.get_by_user_and_recipe(self.user.id, recipe_id)
if user_rating:
return user_rating
else:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
ErrorResponse.respond("User has not rated this recipe"),
)
@user_router.get("/self/favorites", response_model=UserRatings[UserRatingSummary])
def get_logged_in_user_favorites(self):
return UserRatings(ratings=self.repos.user_ratings.get_by_user(self.user.id, favorites_only=True))
@user_router.get("/self/group", response_model=GroupInDB)
def get_logged_in_user_group(self):
return self.group

View File

@ -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

View 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))

View File

@ -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",
]

View File

@ -17,26 +17,9 @@ from .restore import (
from .settings import CustomPageBase, CustomPageOut
__all__ = [
"AllBackups",
"BackupFile",
"BackupOptions",
"CreateBackup",
"ImportJob",
"EmailReady",
"EmailSuccess",
"EmailTest",
"CustomPageBase",
"CustomPageOut",
"MaintenanceLogs",
"MaintenanceStorageDetails",
"MaintenanceSummary",
"AdminAboutInfo",
"AppInfo",
"AppStartupInfo",
"AppStatistics",
"AppTheme",
"CheckAppConfig",
"OIDCInfo",
"CommentImport",
"CustomPageImport",
"GroupImport",
@ -45,8 +28,25 @@ __all__ = [
"RecipeImport",
"SettingsImport",
"UserImport",
"EmailReady",
"EmailSuccess",
"EmailTest",
"CustomPageBase",
"CustomPageOut",
"AdminAboutInfo",
"AppInfo",
"AppStartupInfo",
"AppStatistics",
"AppTheme",
"CheckAppConfig",
"OIDCInfo",
"ChowdownURL",
"MigrationFile",
"MigrationImport",
"Migrations",
"AllBackups",
"BackupFile",
"BackupOptions",
"CreateBackup",
"ImportJob",
]

View File

@ -45,36 +45,6 @@ from .invite_token import CreateInviteToken, EmailInitationResponse, EmailInvita
from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType
__all__ = [
"CreateWebhook",
"ReadWebhook",
"SaveWebhook",
"WebhookPagination",
"WebhookType",
"GroupDataExport",
"GroupEventNotifierCreate",
"GroupEventNotifierOptions",
"GroupEventNotifierOptionsOut",
"GroupEventNotifierOptionsSave",
"GroupEventNotifierOut",
"GroupEventNotifierPrivate",
"GroupEventNotifierSave",
"GroupEventNotifierUpdate",
"GroupEventPagination",
"CreateGroupPreferences",
"ReadGroupPreferences",
"UpdateGroupPreferences",
"GroupStatistics",
"GroupStorage",
"GroupAdminUpdate",
"DataMigrationCreate",
"SupportedMigrations",
"SeederConfig",
"SetPermissions",
"CreateInviteToken",
"EmailInitationResponse",
"EmailInvitation",
"ReadInviteToken",
"SaveInviteToken",
"ShoppingListAddRecipeParams",
"ShoppingListCreate",
"ShoppingListItemBase",
@ -97,4 +67,34 @@ __all__ = [
"ShoppingListSave",
"ShoppingListSummary",
"ShoppingListUpdate",
"CreateWebhook",
"ReadWebhook",
"SaveWebhook",
"WebhookPagination",
"WebhookType",
"GroupAdminUpdate",
"CreateGroupPreferences",
"ReadGroupPreferences",
"UpdateGroupPreferences",
"SetPermissions",
"DataMigrationCreate",
"SupportedMigrations",
"SeederConfig",
"GroupDataExport",
"CreateInviteToken",
"EmailInitationResponse",
"EmailInvitation",
"ReadInviteToken",
"SaveInviteToken",
"GroupStatistics",
"GroupStorage",
"GroupEventNotifierCreate",
"GroupEventNotifierOptions",
"GroupEventNotifierOptionsOut",
"GroupEventNotifierOptionsSave",
"GroupEventNotifierOut",
"GroupEventNotifierPrivate",
"GroupEventNotifierSave",
"GroupEventNotifierUpdate",
"GroupEventPagination",
]

View File

@ -30,6 +30,9 @@ __all__ = [
"PlanRulesSave",
"PlanRulesType",
"Tag",
"ListItem",
"ShoppingListIn",
"ShoppingListOut",
"CreatePlanEntry",
"CreateRandomEntry",
"PlanEntryPagination",
@ -37,9 +40,6 @@ __all__ = [
"ReadPlanEntry",
"SavePlanEntry",
"UpdatePlanEntry",
"ListItem",
"ShoppingListIn",
"ShoppingListOut",
"MealDayIn",
"MealDayOut",
"MealIn",

View File

@ -88,8 +88,20 @@ from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, Re
from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse
__all__ = [
"Nutrition",
"RecipeSettings",
"RecipeToolCreate",
"RecipeToolOut",
"RecipeToolResponse",
"RecipeToolSave",
"CategoryBase",
"CategoryIn",
"CategoryOut",
"CategorySave",
"RecipeCategoryResponse",
"RecipeTagResponse",
"TagBase",
"TagIn",
"TagOut",
"TagSave",
"AssignCategories",
"AssignSettings",
"AssignTags",
@ -97,12 +109,34 @@ __all__ = [
"ExportBase",
"ExportRecipes",
"ExportTypes",
"RecipeNote",
"RecipeDuplicate",
"RecipeSlug",
"RecipeZipTokenResponse",
"SlugResponse",
"UpdateImageResponse",
"RecipeShareToken",
"RecipeShareTokenCreate",
"RecipeShareTokenSave",
"RecipeShareTokenSummary",
"ScrapeRecipe",
"ScrapeRecipeTest",
"RecipeCommentCreate",
"RecipeCommentOut",
"RecipeCommentPagination",
"RecipeCommentSave",
"RecipeCommentUpdate",
"UserBase",
"RecipeImageTypes",
"CreateRecipe",
"CreateRecipeBulk",
"CreateRecipeByUrlBulk",
"Recipe",
"RecipeCategory",
"RecipeCategoryPagination",
"RecipeLastMade",
"RecipePagination",
"RecipeSummary",
"RecipeTag",
"RecipeTagPagination",
"RecipeTool",
"RecipeToolPagination",
"IngredientReferences",
"RecipeStep",
"CreateIngredientFood",
"CreateIngredientFoodAlias",
"CreateIngredientUnit",
@ -125,16 +159,7 @@ __all__ = [
"SaveIngredientFood",
"SaveIngredientUnit",
"UnitFoodBase",
"ScrapeRecipe",
"ScrapeRecipeTest",
"RecipeImageTypes",
"IngredientReferences",
"RecipeStep",
"RecipeAsset",
"RecipeToolCreate",
"RecipeToolOut",
"RecipeToolResponse",
"RecipeToolSave",
"RecipeTimelineEventCreate",
"RecipeTimelineEventIn",
"RecipeTimelineEventOut",
@ -142,37 +167,12 @@ __all__ = [
"RecipeTimelineEventUpdate",
"TimelineEventImage",
"TimelineEventType",
"CreateRecipe",
"CreateRecipeBulk",
"CreateRecipeByUrlBulk",
"Recipe",
"RecipeCategory",
"RecipeCategoryPagination",
"RecipeLastMade",
"RecipePagination",
"RecipeSummary",
"RecipeTag",
"RecipeTagPagination",
"RecipeTool",
"RecipeToolPagination",
"CategoryBase",
"CategoryIn",
"CategoryOut",
"CategorySave",
"RecipeCategoryResponse",
"RecipeTagResponse",
"TagBase",
"TagIn",
"TagOut",
"TagSave",
"RecipeCommentCreate",
"RecipeCommentOut",
"RecipeCommentPagination",
"RecipeCommentSave",
"RecipeCommentUpdate",
"UserBase",
"RecipeShareToken",
"RecipeShareTokenCreate",
"RecipeShareTokenSave",
"RecipeShareTokenSummary",
"RecipeDuplicate",
"RecipeSlug",
"RecipeZipTokenResponse",
"SlugResponse",
"UpdateImageResponse",
"Nutrition",
"RecipeSettings",
"RecipeNote",
]

View File

@ -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

View File

@ -11,6 +11,8 @@ __all__ = [
"QueryFilterComponent",
"RelationalKeyword",
"RelationalOperator",
"SearchFilter",
"ValidationResponse",
"OrderByNullPosition",
"OrderDirection",
"PaginationBase",
@ -19,6 +21,4 @@ __all__ = [
"ErrorResponse",
"FileTokenResponse",
"SuccessResponse",
"ValidationResponse",
"SearchFilter",
]

View File

@ -14,11 +14,15 @@ from .user import (
PrivateUser,
UpdateGroup,
UserBase,
UserFavorites,
UserIn,
UserOut,
UserPagination,
UserRatingCreate,
UserRatingOut,
UserRatings,
UserRatingSummary,
UserSummary,
UserSummaryPagination,
)
from .user_passwords import (
ForgotPassword,
@ -55,9 +59,13 @@ __all__ = [
"PrivateUser",
"UpdateGroup",
"UserBase",
"UserFavorites",
"UserIn",
"UserOut",
"UserPagination",
"UserRatingCreate",
"UserRatingOut",
"UserRatingSummary",
"UserRatings",
"UserSummary",
"UserSummaryPagination",
]

View File

@ -1,9 +1,9 @@
from datetime import datetime, timedelta
from pathlib import Path
from typing import Annotated, Any
from typing import Annotated, Any, Generic, TypeVar
from uuid import UUID
from pydantic import UUID4, ConfigDict, Field, StringConstraints, field_validator
from pydantic import UUID4, BaseModel, ConfigDict, Field, StringConstraints, field_validator
from sqlalchemy.orm import joinedload, selectinload
from sqlalchemy.orm.interfaces import LoaderOption
@ -13,13 +13,12 @@ from mealie.db.models.users.users import AuthMethod
from mealie.schema._mealie import MealieModel
from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.group.webhook import CreateWebhook, ReadWebhook
from mealie.schema.recipe import RecipeSummary
from mealie.schema.response.pagination import PaginationBase
from ...db.models.group import Group
from ...db.models.recipe import RecipeModel
from ..recipe import CategoryBase
DataT = TypeVar("DataT", bound=BaseModel)
DEFAULT_INTEGRATION_ID = "generic"
settings = get_app_settings()
@ -58,6 +57,38 @@ class GroupBase(MealieModel):
model_config = ConfigDict(from_attributes=True)
class UserRatingSummary(MealieModel):
recipe_id: UUID4
rating: float | None = None
is_favorite: Annotated[bool, Field(validate_default=True)] = False
model_config = ConfigDict(from_attributes=True)
@field_validator("is_favorite", mode="before")
def convert_is_favorite(cls, v: Any) -> bool:
if v is None:
return False
else:
return v
class UserRatingCreate(UserRatingSummary):
user_id: UUID4
class UserRatingUpdate(MealieModel):
rating: float | None = None
is_favorite: bool | None = None
class UserRatingOut(UserRatingCreate):
id: UUID4
class UserRatings(BaseModel, Generic[DataT]):
ratings: list[DataT]
class UserBase(MealieModel):
id: UUID4 | None = None
username: str | None = None
@ -67,7 +98,6 @@ class UserBase(MealieModel):
admin: bool = False
group: str | None = None
advanced: bool = False
favorite_recipes: list[str] | None = []
can_invite: bool = False
can_manage: bool = False
@ -107,7 +137,6 @@ class UserOut(UserBase):
group_slug: str
tokens: list[LongLiveTokenOut] | None = None
cache_key: str
favorite_recipes: Annotated[list[str], Field(validate_default=True)] = []
model_config = ConfigDict(from_attributes=True)
@property
@ -116,27 +145,7 @@ class UserOut(UserBase):
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [joinedload(User.group), joinedload(User.favorite_recipes), joinedload(User.tokens)]
@field_validator("favorite_recipes", mode="before")
def convert_favorite_recipes_to_slugs(cls, v: Any):
if not v:
return []
if not isinstance(v, list):
return v
slugs: list[str] = []
for recipe in v:
if isinstance(recipe, str):
slugs.append(recipe)
else:
try:
slugs.append(recipe.slug)
except AttributeError:
# this isn't a list of recipes, so we quit early and let Pydantic's typical validation handle it
return v
return slugs
return [joinedload(User.group), joinedload(User.tokens)]
class UserSummary(MealieModel):
@ -153,20 +162,6 @@ class UserSummaryPagination(PaginationBase):
items: list[UserSummary]
class UserFavorites(UserBase):
favorite_recipes: list[RecipeSummary] = [] # type: ignore
model_config = ConfigDict(from_attributes=True)
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [
joinedload(User.group),
selectinload(User.favorite_recipes).joinedload(RecipeModel.recipe_category),
selectinload(User.favorite_recipes).joinedload(RecipeModel.tags),
selectinload(User.favorite_recipes).joinedload(RecipeModel.tools),
]
class PrivateUser(UserOut):
password: str
group_id: UUID4
@ -198,7 +193,7 @@ class PrivateUser(UserOut):
@classmethod
def loader_options(cls) -> list[LoaderOption]:
return [joinedload(User.group), selectinload(User.favorite_recipes), joinedload(User.tokens)]
return [joinedload(User.group), joinedload(User.tokens)]
class UpdateGroup(GroupBase):
@ -244,7 +239,6 @@ class GroupInDB(UpdateGroup):
joinedload(Group.webhooks),
joinedload(Group.preferences),
selectinload(Group.users).joinedload(User.group),
selectinload(Group.users).joinedload(User.favorite_recipes),
selectinload(Group.users).joinedload(User.tokens),
]

View File

@ -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),
]

View File

@ -20,7 +20,7 @@ from mealie.schema.recipe.recipe_settings import RecipeSettings
from mealie.schema.recipe.recipe_step import RecipeStep
from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType
from mealie.schema.recipe.request_helpers import RecipeDuplicate
from mealie.schema.user.user import GroupInDB, PrivateUser
from mealie.schema.user.user import GroupInDB, PrivateUser, UserRatingCreate
from mealie.services._base_service import BaseService
from mealie.services.recipe.recipe_data_service import RecipeDataService
@ -145,8 +145,20 @@ class RecipeService(BaseService):
else:
data.settings = RecipeSettings()
rating_input = data.rating
new_recipe = self.repos.recipes.create(data)
# convert rating into user rating
if rating_input:
self.repos.user_ratings.create(
UserRatingCreate(
user_id=self.user.id,
recipe_id=new_recipe.id,
rating=rating_input,
is_favorite=False,
)
)
# create first timeline entry
timeline_event_data = RecipeTimelineEventCreate(
user_id=new_recipe.user_id,

View File

@ -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

View 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

View File

@ -1,5 +1,6 @@
from datetime import datetime
from typing import cast
from uuid import UUID
import pytest
@ -10,7 +11,7 @@ from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipeSummary
from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave, TagSave
from mealie.schema.recipe.recipe_tool import RecipeToolSave
from mealie.schema.response import OrderDirection, PaginationQuery
from mealie.schema.user.user import GroupBase
from mealie.schema.user.user import GroupBase, UserRatingCreate
from tests.utils.factories import random_email, random_string
from tests.utils.fixture_schemas import TestUser
@ -658,3 +659,126 @@ def test_random_order_recipe_search(
pagination.pagination_seed = str(datetime.now())
random_ordered.append(repo.page_all(pagination, search="soup").items)
assert not all(i == random_ordered[0] for i in random_ordered)
def test_order_by_rating(database: AllRepositories, user_tuple: tuple[TestUser, TestUser]):
user_1, user_2 = user_tuple
repo = database.recipes.by_group(UUID(user_1.group_id))
recipes: list[Recipe] = []
for i in range(3):
slug = f"recipe-{i+1}-{random_string(5)}"
recipes.append(
database.recipes.create(
Recipe(
user_id=user_1.user_id,
group_id=user_1.group_id,
name=slug,
slug=slug,
)
)
)
# set the rating for user_1 and confirm both users see the same ordering
recipe_1, recipe_2, recipe_3 = recipes
database.user_ratings.create(
UserRatingCreate(
user_id=user_1.user_id,
recipe_id=recipe_1.id,
rating=5,
)
)
database.user_ratings.create(
UserRatingCreate(
user_id=user_1.user_id,
recipe_id=recipe_2.id,
rating=4,
)
)
database.user_ratings.create(
UserRatingCreate(
user_id=user_1.user_id,
recipe_id=recipe_3.id,
rating=3,
)
)
pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.desc)
data_1 = repo.by_user(user_1.user_id).page_all(pq).items
data_2 = repo.by_user(user_2.user_id).page_all(pq).items
for data in [data_1, data_2]:
assert len(data) == 3
assert data[0].slug == recipe_1.slug # global and user rating == 5
assert data[1].slug == recipe_2.slug # global and user rating == 4
assert data[2].slug == recipe_3.slug # global and user rating == 3
pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.asc)
data_1 = repo.by_user(user_1.user_id).page_all(pq).items
data_2 = repo.by_user(user_2.user_id).page_all(pq).items
for data in [data_1, data_2]:
assert len(data) == 3
assert data[0].slug == recipe_3.slug # global and user rating == 3
assert data[1].slug == recipe_2.slug # global and user rating == 4
assert data[2].slug == recipe_1.slug # global and user rating == 5
# set rating for one recipe for user_2 and confirm user_2 sees the correct order and user_1's order is unchanged
database.user_ratings.create(
UserRatingCreate(
user_id=user_2.user_id,
recipe_id=recipe_1.id,
rating=3.5,
)
)
pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.desc)
data_1 = repo.by_user(user_1.user_id).page_all(pq).items
data_2 = repo.by_user(user_2.user_id).page_all(pq).items
assert len(data_1) == 3
assert data_1[0].slug == recipe_1.slug # user rating == 5
assert data_1[1].slug == recipe_2.slug # user rating == 4
assert data_1[2].slug == recipe_3.slug # user rating == 3
assert len(data_2) == 3
assert data_2[0].slug == recipe_2.slug # global rating == 4
assert data_2[1].slug == recipe_1.slug # user rating == 3.5
assert data_2[2].slug == recipe_3.slug # user rating == 3
pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.asc)
data_1 = repo.by_user(user_1.user_id).page_all(pq).items
data_2 = repo.by_user(user_2.user_id).page_all(pq).items
assert len(data_1) == 3
assert data_1[0].slug == recipe_3.slug # global and user rating == 3
assert data_1[1].slug == recipe_2.slug # global and user rating == 4
assert data_1[2].slug == recipe_1.slug # global and user rating == 5
assert len(data_2) == 3
assert data_2[0].slug == recipe_3.slug # user rating == 3
assert data_2[1].slug == recipe_1.slug # user rating == 3.5
assert data_2[2].slug == recipe_2.slug # global rating == 4
# verify public users see only global ratings
database.user_ratings.create(
UserRatingCreate(
user_id=user_2.user_id,
recipe_id=recipe_2.id,
rating=1,
)
)
pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.desc)
data = database.recipes.by_group(UUID(user_1.group_id)).page_all(pq).items
assert len(data) == 3
assert data[0].slug == recipe_1.slug # global rating == 4.25 (avg of 5 and 3.5)
assert data[1].slug == recipe_3.slug # global rating == 3
assert data[2].slug == recipe_2.slug # global rating == 2.5 (avg of 4 and 1)
pq = PaginationQuery(page=1, per_page=-1, order_by="rating", order_direction=OrderDirection.asc)
data = database.recipes.by_group(UUID(user_1.group_id)).page_all(pq).items
assert len(data) == 3
assert data[0].slug == recipe_2.slug # global rating == 2.5 (avg of 4 and 1)
assert data[1].slug == recipe_3.slug # global rating == 3
assert data[2].slug == recipe_1.slug # global rating == 4.25 (avg of 5 and 3.5)

View File

@ -1,4 +1,5 @@
import filecmp
import statistics
from pathlib import Path
from typing import Any, cast
@ -8,11 +9,14 @@ from sqlalchemy.orm import Session
import tests.data as test_data
from mealie.core.config import get_app_settings
from mealie.db.db_setup import session_context
from mealie.db.models._model_utils import GUID
from mealie.db.models.group import Group
from mealie.db.models.group.shopping_list import ShoppingList
from mealie.db.models.labels import MultiPurposeLabel
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.users.user_to_recipe import UserToRecipe
from mealie.db.models.users.users import User
from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter
from mealie.services.backups_v2.backup_file import BackupFile
from mealie.services.backups_v2.backup_v2 import BackupV2
@ -155,5 +159,18 @@ def test_database_restore_data(backup_path: Path):
assert unit.name_normalized
if unit.abbreviation:
assert unit.abbreviation_normalized
# 2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_ratings
users_by_group_id: dict[GUID, list[User]] = {}
for recipe in recipes:
users = users_by_group_id.get(recipe.group_id)
if users is None:
users = session.query(User).filter(User.group_id == recipe.group_id).all()
users_by_group_id[recipe.group_id] = users
user_to_recipes = session.query(UserToRecipe).filter(UserToRecipe.recipe_id == recipe.id).all()
user_ratings = [x.rating for x in user_to_recipes if x.rating]
assert recipe.rating == (statistics.mean(user_ratings) if user_ratings else None)
finally:
backup_v2.restore(original_data_backup)

View File

@ -181,8 +181,12 @@ users_reset_password = "/api/users/reset-password"
"""`/api/users/reset-password`"""
users_self = "/api/users/self"
"""`/api/users/self`"""
users_self_favorites = "/api/users/self/favorites"
"""`/api/users/self/favorites`"""
users_self_group = "/api/users/self/group"
"""`/api/users/self/group`"""
users_self_ratings = "/api/users/self/ratings"
"""`/api/users/self/ratings`"""
utils_download = "/api/utils/download"
"""`/api/utils/download`"""
validators_group = "/api/validators/group"
@ -490,6 +494,21 @@ def users_id_image(id):
return f"{prefix}/users/{id}/image"
def users_id_ratings(id):
"""`/api/users/{id}/ratings`"""
return f"{prefix}/users/{id}/ratings"
def users_id_ratings_slug(id, slug):
"""`/api/users/{id}/ratings/{slug}`"""
return f"{prefix}/users/{id}/ratings/{slug}"
def users_item_id(item_id):
"""`/api/users/{item_id}`"""
return f"{prefix}/users/{item_id}"
def users_self_ratings_recipe_id(recipe_id):
"""`/api/users/self/ratings/{recipe_id}`"""
return f"{prefix}/users/self/ratings/{recipe_id}"