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
50 changed files with 1497 additions and 443 deletions

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,