mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-04-17 10:25:34 -04:00
feat: User-specific Recipe Ratings (#3345)
This commit is contained in:
@@ -7,12 +7,14 @@ from pydantic import ConfigDict
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.orderinglist import ordering_list
|
||||
from sqlalchemy.orm import Mapped, mapped_column, validates
|
||||
from sqlalchemy.orm.attributes import get_history
|
||||
from sqlalchemy.orm.session import object_session
|
||||
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from ..users.user_to_favorite import users_to_favorites
|
||||
from ..users.user_to_recipe import UserToRecipe
|
||||
from .api_extras import ApiExtras, api_extras
|
||||
from .assets import RecipeAsset
|
||||
from .category import recipes_to_categories
|
||||
@@ -49,12 +51,20 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
user_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("users.id", use_alter=True), index=True)
|
||||
user: Mapped["User"] = orm.relationship("User", uselist=False, foreign_keys=[user_id])
|
||||
|
||||
meal_entries: Mapped[list["GroupMealPlan"]] = orm.relationship(
|
||||
"GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan"
|
||||
rating: Mapped[float | None] = mapped_column(sa.Float, index=True, nullable=True)
|
||||
rated_by: Mapped[list["User"]] = orm.relationship(
|
||||
"User", secondary=UserToRecipe.__tablename__, back_populates="rated_recipes"
|
||||
)
|
||||
favorited_by: Mapped[list["User"]] = orm.relationship(
|
||||
"User",
|
||||
secondary=UserToRecipe.__tablename__,
|
||||
primaryjoin="and_(RecipeModel.id==UserToRecipe.recipe_id, UserToRecipe.is_favorite==True)",
|
||||
back_populates="favorite_recipes",
|
||||
viewonly=True,
|
||||
)
|
||||
|
||||
favorited_by: Mapped[list["User"]] = orm.relationship(
|
||||
"User", secondary=users_to_favorites, back_populates="favorite_recipes"
|
||||
meal_entries: Mapped[list["GroupMealPlan"]] = orm.relationship(
|
||||
"GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# General Recipe Properties
|
||||
@@ -110,7 +120,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
|
||||
)
|
||||
tags: Mapped[list["Tag"]] = orm.relationship("Tag", secondary=recipes_to_tags, back_populates="recipes")
|
||||
notes: Mapped[list[Note]] = orm.relationship("Note", cascade="all, delete-orphan")
|
||||
rating: Mapped[int | None] = mapped_column(sa.Integer)
|
||||
org_url: Mapped[str | None] = mapped_column(sa.String)
|
||||
extras: Mapped[list[ApiExtras]] = orm.relationship("ApiExtras", cascade="all, delete-orphan")
|
||||
is_ocr_recipe: Mapped[bool | None] = mapped_column(sa.Boolean, default=False)
|
||||
@@ -246,3 +255,23 @@ def receive_description(target: RecipeModel, value: str, oldvalue, initiator):
|
||||
target.description_normalized = RecipeModel.normalize(value)
|
||||
else:
|
||||
target.description_normalized = None
|
||||
|
||||
|
||||
@event.listens_for(RecipeModel, "before_update")
|
||||
def calculate_rating(mapper, connection, target: RecipeModel):
|
||||
session = object_session(target)
|
||||
if not session:
|
||||
return
|
||||
|
||||
if session.is_modified(target, "rating"):
|
||||
history = get_history(target, "rating")
|
||||
old_value = history.deleted[0] if history.deleted else None
|
||||
new_value = history.added[0] if history.added else None
|
||||
if old_value == new_value:
|
||||
return
|
||||
|
||||
target.rating = (
|
||||
session.query(sa.func.avg(UserToRecipe.rating))
|
||||
.filter(UserToRecipe.recipe_id == target.id, UserToRecipe.rating is not None, UserToRecipe.rating > 0)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from .password_reset import *
|
||||
from .user_to_favorite import *
|
||||
from .user_to_recipe import *
|
||||
from .users import *
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
from sqlalchemy import Column, ForeignKey, Table, UniqueConstraint
|
||||
|
||||
from .._model_base import SqlAlchemyBase
|
||||
from .._model_utils import GUID
|
||||
|
||||
users_to_favorites = Table(
|
||||
"users_to_favorites",
|
||||
SqlAlchemyBase.metadata,
|
||||
Column("user_id", GUID, ForeignKey("users.id"), index=True),
|
||||
Column("recipe_id", GUID, ForeignKey("recipes.id"), index=True),
|
||||
UniqueConstraint("user_id", "recipe_id", name="user_id_recipe_id_key"),
|
||||
)
|
||||
42
mealie/db/models/users/user_to_recipe.py
Normal file
42
mealie/db/models/users/user_to_recipe.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from sqlalchemy import Boolean, Column, Float, ForeignKey, UniqueConstraint, event
|
||||
from sqlalchemy.engine.base import Connection
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import GUID, auto_init
|
||||
|
||||
|
||||
class UserToRecipe(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "users_to_recipes"
|
||||
__table_args__ = (UniqueConstraint("user_id", "recipe_id", name="user_id_recipe_id_rating_key"),)
|
||||
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
|
||||
user_id = Column(GUID, ForeignKey("users.id"), index=True, primary_key=True)
|
||||
recipe_id = Column(GUID, ForeignKey("recipes.id"), index=True, primary_key=True)
|
||||
rating = Column(Float, index=True, nullable=True)
|
||||
is_favorite = Column(Boolean, index=True, nullable=False)
|
||||
|
||||
@auto_init()
|
||||
def __init__(self, **_) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def update_recipe_rating(session: Session, target: UserToRecipe):
|
||||
from mealie.db.models.recipe.recipe import RecipeModel
|
||||
|
||||
recipe = session.query(RecipeModel).filter(RecipeModel.id == target.recipe_id).first()
|
||||
if not recipe:
|
||||
return
|
||||
|
||||
recipe.rating = -1 # this will trigger the recipe to re-calculate the rating
|
||||
|
||||
|
||||
@event.listens_for(UserToRecipe, "after_insert")
|
||||
@event.listens_for(UserToRecipe, "after_update")
|
||||
@event.listens_for(UserToRecipe, "after_delete")
|
||||
def update_recipe_rating_on_insert_or_delete(_, connection: Connection, target: UserToRecipe):
|
||||
session = Session(bind=connection)
|
||||
|
||||
update_recipe_rating(session, target)
|
||||
session.commit()
|
||||
@@ -12,7 +12,7 @@ from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
from .._model_base import BaseMixins, SqlAlchemyBase
|
||||
from .._model_utils import auto_init
|
||||
from .user_to_favorite import users_to_favorites
|
||||
from .user_to_recipe import UserToRecipe
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..group import Group
|
||||
@@ -49,7 +49,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
username: Mapped[str | None] = mapped_column(String, index=True, unique=True)
|
||||
email: Mapped[str | None] = mapped_column(String, unique=True, index=True)
|
||||
password: Mapped[str | None] = mapped_column(String)
|
||||
auth_method: Mapped[Enum(AuthMethod)] = mapped_column(Enum(AuthMethod), default=AuthMethod.MEALIE)
|
||||
auth_method: Mapped[Enum[AuthMethod]] = mapped_column(Enum(AuthMethod), default=AuthMethod.MEALIE)
|
||||
admin: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||
advanced: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||
|
||||
@@ -84,8 +84,15 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
"GroupMealPlan", order_by="GroupMealPlan.date", **sp_args
|
||||
)
|
||||
shopping_lists: Mapped[Optional["ShoppingList"]] = orm.relationship("ShoppingList", **sp_args)
|
||||
rated_recipes: Mapped[list["RecipeModel"]] = orm.relationship(
|
||||
"RecipeModel", secondary=UserToRecipe.__tablename__, back_populates="rated_by"
|
||||
)
|
||||
favorite_recipes: Mapped[list["RecipeModel"]] = orm.relationship(
|
||||
"RecipeModel", secondary=users_to_favorites, back_populates="favorited_by"
|
||||
"RecipeModel",
|
||||
secondary=UserToRecipe.__tablename__,
|
||||
primaryjoin="and_(User.id==UserToRecipe.user_id, UserToRecipe.is_favorite==True)",
|
||||
back_populates="favorited_by",
|
||||
viewonly=True,
|
||||
)
|
||||
model_config = ConfigDict(
|
||||
exclude={
|
||||
@@ -112,7 +119,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||
|
||||
self.group = Group.get_by_name(session, group)
|
||||
|
||||
self.favorite_recipes = []
|
||||
self.rated_recipes = []
|
||||
|
||||
self.password = password
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ from mealie.db.models.recipe.tool import Tool
|
||||
from mealie.db.models.server.task import ServerTaskModel
|
||||
from mealie.db.models.users import LongLiveToken, User
|
||||
from mealie.db.models.users.password_reset import PasswordResetModel
|
||||
from mealie.db.models.users.user_to_recipe import UserToRecipe
|
||||
from mealie.repos.repository_foods import RepositoryFood
|
||||
from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules
|
||||
from mealie.repos.repository_units import RepositoryUnit
|
||||
@@ -58,6 +59,7 @@ from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventOut
|
||||
from mealie.schema.reports.reports import ReportEntryOut, ReportOut
|
||||
from mealie.schema.server import ServerTask
|
||||
from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser
|
||||
from mealie.schema.user.user import UserRatingOut
|
||||
from mealie.schema.user.user_passwords import PrivatePasswordResetToken
|
||||
|
||||
from .repository_generic import RepositoryGeneric
|
||||
@@ -65,7 +67,7 @@ from .repository_group import RepositoryGroup
|
||||
from .repository_meals import RepositoryMeals
|
||||
from .repository_recipes import RepositoryRecipes
|
||||
from .repository_shopping_list import RepositoryShoppingList
|
||||
from .repository_users import RepositoryUsers
|
||||
from .repository_users import RepositoryUserRatings, RepositoryUsers
|
||||
|
||||
PK_ID = "id"
|
||||
PK_SLUG = "slug"
|
||||
@@ -143,6 +145,10 @@ class AllRepositories:
|
||||
def users(self) -> RepositoryUsers:
|
||||
return RepositoryUsers(self.session, PK_ID, User, PrivateUser)
|
||||
|
||||
@cached_property
|
||||
def user_ratings(self) -> RepositoryUserRatings:
|
||||
return RepositoryUserRatings(self.session, PK_ID, UserToRecipe, UserRatingOut)
|
||||
|
||||
@cached_property
|
||||
def api_tokens(self) -> RepositoryGeneric[LongLiveTokenInDB, LongLiveToken]:
|
||||
return RepositoryGeneric(self.session, PK_ID, LongLiveToken, LongLiveTokenInDB)
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any, Generic, TypeVar
|
||||
from fastapi import HTTPException
|
||||
from pydantic import UUID4, BaseModel
|
||||
from sqlalchemy import Select, case, delete, func, nulls_first, nulls_last, select
|
||||
from sqlalchemy.orm import InstrumentedAttribute
|
||||
from sqlalchemy.orm.session import Session
|
||||
from sqlalchemy.sql import sqltypes
|
||||
|
||||
@@ -67,9 +68,6 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||
def _filter_builder(self, **kwargs) -> dict[str, Any]:
|
||||
dct = {}
|
||||
|
||||
if self.user_id:
|
||||
dct["user_id"] = self.user_id
|
||||
|
||||
if self.group_id:
|
||||
dct["group_id"] = self.group_id
|
||||
|
||||
@@ -287,7 +285,7 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||
pagination is a method to interact with the filtered database table and return a paginated result
|
||||
using the PaginationBase that provides several data points that are needed to manage pagination
|
||||
on the client side. This method does utilize the _filter_build method to ensure that the results
|
||||
are filtered by the user and group id when applicable.
|
||||
are filtered by the group id when applicable.
|
||||
|
||||
NOTE: When you provide an override you'll need to manually type the result of this method
|
||||
as the override, as the type system is not able to infer the result of this method.
|
||||
@@ -368,6 +366,29 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||
query = self.add_order_by_to_query(query, pagination)
|
||||
return query.limit(pagination.per_page).offset((pagination.page - 1) * pagination.per_page), count, total_pages
|
||||
|
||||
def add_order_attr_to_query(
|
||||
self,
|
||||
query: Select,
|
||||
order_attr: InstrumentedAttribute,
|
||||
order_dir: OrderDirection,
|
||||
order_by_null: OrderByNullPosition | None,
|
||||
) -> Select:
|
||||
if order_dir is OrderDirection.asc:
|
||||
order_attr = order_attr.asc()
|
||||
elif order_dir is OrderDirection.desc:
|
||||
order_attr = order_attr.desc()
|
||||
|
||||
# queries handle uppercase and lowercase differently, which is undesirable
|
||||
if isinstance(order_attr.type, sqltypes.String):
|
||||
order_attr = func.lower(order_attr)
|
||||
|
||||
if order_by_null is OrderByNullPosition.first:
|
||||
order_attr = nulls_first(order_attr)
|
||||
elif order_by_null is OrderByNullPosition.last:
|
||||
order_attr = nulls_last(order_attr)
|
||||
|
||||
return query.order_by(order_attr)
|
||||
|
||||
def add_order_by_to_query(self, query: Select, pagination: PaginationQuery) -> Select:
|
||||
if not pagination.order_by:
|
||||
return query
|
||||
@@ -399,21 +420,9 @@ class RepositoryGeneric(Generic[Schema, Model]):
|
||||
order_by, self.model, query=query
|
||||
)
|
||||
|
||||
if order_dir is OrderDirection.asc:
|
||||
order_attr = order_attr.asc()
|
||||
elif order_dir is OrderDirection.desc:
|
||||
order_attr = order_attr.desc()
|
||||
|
||||
# queries handle uppercase and lowercase differently, which is undesirable
|
||||
if isinstance(order_attr.type, sqltypes.String):
|
||||
order_attr = func.lower(order_attr)
|
||||
|
||||
if pagination.order_by_null_position is OrderByNullPosition.first:
|
||||
order_attr = nulls_first(order_attr)
|
||||
elif pagination.order_by_null_position is OrderByNullPosition.last:
|
||||
order_attr = nulls_last(order_attr)
|
||||
|
||||
query = query.order_by(order_attr)
|
||||
query = self.add_order_attr_to_query(
|
||||
query, order_attr, order_dir, pagination.order_by_null_position
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -3,11 +3,11 @@ from collections.abc import Sequence
|
||||
from random import randint
|
||||
from uuid import UUID
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pydantic import UUID4
|
||||
from slugify import slugify
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.orm import InstrumentedAttribute, joinedload
|
||||
|
||||
from mealie.db.models.recipe.category import Category
|
||||
from mealie.db.models.recipe.ingredient import RecipeIngredientModel
|
||||
@@ -15,11 +15,12 @@ from mealie.db.models.recipe.recipe import RecipeModel
|
||||
from mealie.db.models.recipe.settings import RecipeSettings
|
||||
from mealie.db.models.recipe.tag import Tag
|
||||
from mealie.db.models.recipe.tool import Tool
|
||||
from mealie.db.models.users.user_to_recipe import UserToRecipe
|
||||
from mealie.schema.cookbook.cookbook import ReadCookBook
|
||||
from mealie.schema.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary, RecipeTag, RecipeTool
|
||||
from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
|
||||
from mealie.schema.response.pagination import PaginationQuery
|
||||
from mealie.schema.response.pagination import OrderByNullPosition, OrderDirection, PaginationQuery
|
||||
|
||||
from ..db.models._model_base import SqlAlchemyBase
|
||||
from ..schema._mealie.mealie_model import extract_uuids
|
||||
@@ -51,7 +52,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
if order_by:
|
||||
order_attr = getattr(self.model, str(order_by))
|
||||
stmt = (
|
||||
select(self.model)
|
||||
sa.select(self.model)
|
||||
.join(RecipeSettings)
|
||||
.filter(RecipeSettings.public == True) # noqa: E712
|
||||
.order_by(order_attr.desc())
|
||||
@@ -61,7 +62,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
return [eff_schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
|
||||
|
||||
stmt = (
|
||||
select(self.model)
|
||||
sa.select(self.model)
|
||||
.join(RecipeSettings)
|
||||
.filter(RecipeSettings.public == True) # noqa: E712
|
||||
.offset(start)
|
||||
@@ -121,7 +122,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
order_attr = order_attr.asc()
|
||||
|
||||
stmt = (
|
||||
select(RecipeModel)
|
||||
sa.select(RecipeModel)
|
||||
.options(*args)
|
||||
.filter(RecipeModel.group_id == group_id)
|
||||
.order_by(order_attr)
|
||||
@@ -145,9 +146,54 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
ids.append(i_as_uuid)
|
||||
except ValueError:
|
||||
slugs.append(i)
|
||||
additional_ids = self.session.execute(select(model.id).filter(model.slug.in_(slugs))).scalars().all()
|
||||
additional_ids = self.session.execute(sa.select(model.id).filter(model.slug.in_(slugs))).scalars().all()
|
||||
return ids + additional_ids
|
||||
|
||||
def add_order_attr_to_query(
|
||||
self,
|
||||
query: sa.Select,
|
||||
order_attr: InstrumentedAttribute,
|
||||
order_dir: OrderDirection,
|
||||
order_by_null: OrderByNullPosition | None,
|
||||
) -> sa.Select:
|
||||
"""Special handling for ordering recipes by rating"""
|
||||
column_name = order_attr.key
|
||||
if column_name != "rating" or not self.user_id:
|
||||
return super().add_order_attr_to_query(query, order_attr, order_dir, order_by_null)
|
||||
|
||||
# calculate the effictive rating for the user by using the user's rating if it exists,
|
||||
# falling back to the recipe's rating if it doesn't
|
||||
effective_rating_column_name = "_effective_rating"
|
||||
query = query.add_columns(
|
||||
sa.case(
|
||||
(
|
||||
sa.exists().where(
|
||||
UserToRecipe.recipe_id == self.model.id,
|
||||
UserToRecipe.user_id == self.user_id,
|
||||
UserToRecipe.rating is not None,
|
||||
UserToRecipe.rating > 0,
|
||||
),
|
||||
sa.select(UserToRecipe.rating)
|
||||
.where(UserToRecipe.recipe_id == self.model.id, UserToRecipe.user_id == self.user_id)
|
||||
.scalar_subquery(),
|
||||
),
|
||||
else_=self.model.rating,
|
||||
).label(effective_rating_column_name)
|
||||
)
|
||||
|
||||
order_attr = effective_rating_column_name
|
||||
if order_dir is OrderDirection.asc:
|
||||
order_attr = sa.asc(order_attr)
|
||||
elif order_dir is OrderDirection.desc:
|
||||
order_attr = sa.desc(order_attr)
|
||||
|
||||
if order_by_null is OrderByNullPosition.first:
|
||||
order_attr = sa.nulls_first(order_attr)
|
||||
elif order_by_null is OrderByNullPosition.last:
|
||||
order_attr = sa.nulls_last(order_attr)
|
||||
|
||||
return query.order_by(order_attr)
|
||||
|
||||
def page_all( # type: ignore
|
||||
self,
|
||||
pagination: PaginationQuery,
|
||||
@@ -165,7 +211,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
) -> RecipePagination:
|
||||
# Copy this, because calling methods (e.g. tests) might rely on it not getting mutated
|
||||
pagination_result = pagination.model_copy()
|
||||
q = select(self.model)
|
||||
q = sa.select(self.model)
|
||||
|
||||
args = [
|
||||
joinedload(RecipeModel.recipe_category),
|
||||
@@ -236,7 +282,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
|
||||
ids = [x.id for x in categories]
|
||||
stmt = (
|
||||
select(RecipeModel)
|
||||
sa.select(RecipeModel)
|
||||
.join(RecipeModel.recipe_category)
|
||||
.filter(RecipeModel.recipe_category.any(Category.id.in_(ids)))
|
||||
)
|
||||
@@ -301,7 +347,7 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
require_all_tags=require_all_tags,
|
||||
require_all_tools=require_all_tools,
|
||||
)
|
||||
stmt = select(RecipeModel).filter(*fltr)
|
||||
stmt = sa.select(RecipeModel).filter(*fltr)
|
||||
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
|
||||
|
||||
def get_random_by_categories_and_tags(
|
||||
@@ -318,26 +364,29 @@ class RepositoryRecipes(RepositoryGeneric[Recipe, RecipeModel]):
|
||||
|
||||
filters = self._build_recipe_filter(extract_uuids(categories), extract_uuids(tags)) # type: ignore
|
||||
stmt = (
|
||||
select(RecipeModel).filter(and_(*filters)).order_by(func.random()).limit(1) # Postgres and SQLite specific
|
||||
sa.select(RecipeModel)
|
||||
.filter(sa.and_(*filters))
|
||||
.order_by(sa.func.random())
|
||||
.limit(1) # Postgres and SQLite specific
|
||||
)
|
||||
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
|
||||
|
||||
def get_random(self, limit=1) -> list[Recipe]:
|
||||
stmt = (
|
||||
select(RecipeModel)
|
||||
sa.select(RecipeModel)
|
||||
.filter(RecipeModel.group_id == self.group_id)
|
||||
.order_by(func.random()) # Postgres and SQLite specific
|
||||
.order_by(sa.func.random()) # Postgres and SQLite specific
|
||||
.limit(limit)
|
||||
)
|
||||
return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()]
|
||||
|
||||
def get_by_slug(self, group_id: UUID4, slug: str, limit=1) -> Recipe | None:
|
||||
stmt = select(RecipeModel).filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug)
|
||||
def get_by_slug(self, group_id: UUID4, slug: str) -> Recipe | None:
|
||||
stmt = sa.select(RecipeModel).filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug)
|
||||
dbrecipe = self.session.execute(stmt).scalars().one_or_none()
|
||||
if dbrecipe is None:
|
||||
return None
|
||||
return self.schema.model_validate(dbrecipe)
|
||||
|
||||
def all_ids(self, group_id: UUID4) -> Sequence[UUID4]:
|
||||
stmt = select(RecipeModel.id).filter(RecipeModel.group_id == group_id)
|
||||
stmt = sa.select(RecipeModel.id).filter(RecipeModel.group_id == group_id)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
@@ -6,7 +6,8 @@ from sqlalchemy import select
|
||||
|
||||
from mealie.assets import users as users_assets
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.schema.user.user import PrivateUser
|
||||
from mealie.db.models.users.user_to_recipe import UserToRecipe
|
||||
from mealie.schema.user.user import PrivateUser, UserRatingOut
|
||||
|
||||
from ..db.models.users import User
|
||||
from .repository_generic import RepositoryGeneric
|
||||
@@ -72,3 +73,26 @@ class RepositoryUsers(RepositoryGeneric[PrivateUser, User]):
|
||||
stmt = select(User).filter(User.locked_at != None) # noqa E711
|
||||
results = self.session.execute(stmt).scalars().all()
|
||||
return [self.schema.model_validate(x) for x in results]
|
||||
|
||||
|
||||
class RepositoryUserRatings(RepositoryGeneric[UserRatingOut, UserToRecipe]):
|
||||
def get_by_user(self, user_id: UUID4, favorites_only=False) -> list[UserRatingOut]:
|
||||
stmt = select(UserToRecipe).filter(UserToRecipe.user_id == user_id)
|
||||
if favorites_only:
|
||||
stmt = stmt.filter(UserToRecipe.is_favorite)
|
||||
|
||||
results = self.session.execute(stmt).scalars().all()
|
||||
return [self.schema.model_validate(x) for x in results]
|
||||
|
||||
def get_by_recipe(self, recipe_id: UUID4, favorites_only=False) -> list[UserRatingOut]:
|
||||
stmt = select(UserToRecipe).filter(UserToRecipe.recipe_id == recipe_id)
|
||||
if favorites_only:
|
||||
stmt = stmt.filter(UserToRecipe.is_favorite)
|
||||
|
||||
results = self.session.execute(stmt).scalars().all()
|
||||
return [self.schema.model_validate(x) for x in results]
|
||||
|
||||
def get_by_user_and_recipe(self, user_id: UUID4, recipe_id: UUID4) -> UserRatingOut | None:
|
||||
stmt = select(UserToRecipe).filter(UserToRecipe.user_id == user_id, UserToRecipe.recipe_id == recipe_id)
|
||||
result = self.session.execute(stmt).scalars().one_or_none()
|
||||
return None if result is None else self.schema.model_validate(result)
|
||||
|
||||
@@ -258,7 +258,8 @@ class RecipeController(BaseRecipeController):
|
||||
if cookbook_data is None:
|
||||
raise HTTPException(status_code=404, detail="cookbook not found")
|
||||
|
||||
pagination_response = self.repo.page_all(
|
||||
# we use the repo by user so we can sort favorites correctly
|
||||
pagination_response = self.repo.by_user(self.user.id).page_all(
|
||||
pagination=q,
|
||||
cookbook=cookbook_data,
|
||||
categories=categories,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import api_tokens, crud, favorites, forgot_password, images, registration
|
||||
from . import api_tokens, crud, forgot_password, images, ratings, registration
|
||||
|
||||
# Must be used because of the way FastAPI works with nested routes
|
||||
user_prefix = "/users"
|
||||
@@ -13,4 +13,4 @@ router.include_router(crud.admin_router)
|
||||
router.include_router(forgot_password.router, prefix=user_prefix, tags=["Users: Passwords"])
|
||||
router.include_router(images.router, prefix=user_prefix, tags=["Users: Images"])
|
||||
router.include_router(api_tokens.router)
|
||||
router.include_router(favorites.router, prefix=user_prefix, tags=["Users: Favorites"])
|
||||
router.include_router(ratings.router, prefix=user_prefix, tags=["Users: Ratings"])
|
||||
|
||||
@@ -11,7 +11,14 @@ from mealie.routes.users._helpers import assert_user_change_allowed
|
||||
from mealie.schema.response import ErrorResponse, SuccessResponse
|
||||
from mealie.schema.response.pagination import PaginationQuery
|
||||
from mealie.schema.user import ChangePassword, UserBase, UserIn, UserOut
|
||||
from mealie.schema.user.user import GroupInDB, UserPagination, UserSummary, UserSummaryPagination
|
||||
from mealie.schema.user.user import (
|
||||
GroupInDB,
|
||||
UserPagination,
|
||||
UserRatings,
|
||||
UserRatingSummary,
|
||||
UserSummary,
|
||||
UserSummaryPagination,
|
||||
)
|
||||
|
||||
user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"])
|
||||
admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"])
|
||||
@@ -74,6 +81,25 @@ class UserController(BaseUserController):
|
||||
def get_logged_in_user(self):
|
||||
return self.user
|
||||
|
||||
@user_router.get("/self/ratings", response_model=UserRatings[UserRatingSummary])
|
||||
def get_logged_in_user_ratings(self):
|
||||
return UserRatings(ratings=self.repos.user_ratings.get_by_user(self.user.id))
|
||||
|
||||
@user_router.get("/self/ratings/{recipe_id}", response_model=UserRatingSummary)
|
||||
def get_logged_in_user_rating_for_recipe(self, recipe_id: UUID4):
|
||||
user_rating = self.repos.user_ratings.get_by_user_and_recipe(self.user.id, recipe_id)
|
||||
if user_rating:
|
||||
return user_rating
|
||||
else:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
ErrorResponse.respond("User has not rated this recipe"),
|
||||
)
|
||||
|
||||
@user_router.get("/self/favorites", response_model=UserRatings[UserRatingSummary])
|
||||
def get_logged_in_user_favorites(self):
|
||||
return UserRatings(ratings=self.repos.user_ratings.get_by_user(self.user.id, favorites_only=True))
|
||||
|
||||
@user_router.get("/self/group", response_model=GroupInDB)
|
||||
def get_logged_in_user_group(self):
|
||||
return self.group
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.routes._base import BaseUserController, controller
|
||||
from mealie.routes._base.routers import UserAPIRouter
|
||||
from mealie.routes.users._helpers import assert_user_change_allowed
|
||||
from mealie.schema.user import UserFavorites
|
||||
|
||||
router = UserAPIRouter()
|
||||
|
||||
|
||||
@controller(router)
|
||||
class UserFavoritesController(BaseUserController):
|
||||
@router.get("/{id}/favorites", response_model=UserFavorites)
|
||||
async def get_favorites(self, id: UUID4):
|
||||
"""Get user's favorite recipes"""
|
||||
return self.repos.users.get_one(id, override_schema=UserFavorites)
|
||||
|
||||
@router.post("/{id}/favorites/{slug}")
|
||||
def add_favorite(self, id: UUID4, slug: str):
|
||||
"""Adds a Recipe to the users favorites"""
|
||||
assert_user_change_allowed(id, self.user)
|
||||
|
||||
if not self.user.favorite_recipes:
|
||||
self.user.favorite_recipes = []
|
||||
|
||||
self.user.favorite_recipes.append(slug)
|
||||
self.repos.users.update(self.user.id, self.user)
|
||||
|
||||
@router.delete("/{id}/favorites/{slug}")
|
||||
def remove_favorite(self, id: UUID4, slug: str):
|
||||
"""Adds a Recipe to the users favorites"""
|
||||
assert_user_change_allowed(id, self.user)
|
||||
|
||||
if not self.user.favorite_recipes:
|
||||
self.user.favorite_recipes = []
|
||||
|
||||
self.user.favorite_recipes = [x for x in self.user.favorite_recipes if x != slug]
|
||||
self.repos.users.update(self.user.id, self.user)
|
||||
return
|
||||
81
mealie/routes/users/ratings.py
Normal file
81
mealie/routes/users/ratings.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.routes._base import BaseUserController, controller
|
||||
from mealie.routes._base.routers import UserAPIRouter
|
||||
from mealie.routes.users._helpers import assert_user_change_allowed
|
||||
from mealie.schema.response.responses import ErrorResponse
|
||||
from mealie.schema.user.user import UserRatingCreate, UserRatingOut, UserRatings, UserRatingUpdate
|
||||
|
||||
router = UserAPIRouter()
|
||||
|
||||
|
||||
@controller(router)
|
||||
class UserRatingsController(BaseUserController):
|
||||
def get_recipe_or_404(self, slug_or_id: str | UUID):
|
||||
"""Fetches a recipe by slug or id, or raises a 404 error if not found."""
|
||||
if isinstance(slug_or_id, str):
|
||||
try:
|
||||
slug_or_id = UUID(slug_or_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
recipes_repo = self.repos.recipes.by_group(self.group_id)
|
||||
if isinstance(slug_or_id, UUID):
|
||||
recipe = recipes_repo.get_one(slug_or_id, key="id")
|
||||
else:
|
||||
recipe = recipes_repo.get_one(slug_or_id, key="slug")
|
||||
|
||||
if not recipe:
|
||||
raise HTTPException(
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
detail=ErrorResponse.respond(message="Not found."),
|
||||
)
|
||||
|
||||
return recipe
|
||||
|
||||
@router.get("/{id}/ratings", response_model=UserRatings[UserRatingOut])
|
||||
async def get_ratings(self, id: UUID4):
|
||||
"""Get user's rated recipes"""
|
||||
return UserRatings(ratings=self.repos.user_ratings.get_by_user(id))
|
||||
|
||||
@router.get("/{id}/favorites", response_model=UserRatings[UserRatingOut])
|
||||
async def get_favorites(self, id: UUID4):
|
||||
"""Get user's favorited recipes"""
|
||||
return UserRatings(ratings=self.repos.user_ratings.get_by_user(id, favorites_only=True))
|
||||
|
||||
@router.post("/{id}/ratings/{slug}")
|
||||
def set_rating(self, id: UUID4, slug: str, data: UserRatingUpdate):
|
||||
"""Sets the user's rating for a recipe"""
|
||||
assert_user_change_allowed(id, self.user)
|
||||
|
||||
recipe = self.get_recipe_or_404(slug)
|
||||
user_rating = self.repos.user_ratings.get_by_user_and_recipe(id, recipe.id)
|
||||
if not user_rating:
|
||||
self.repos.user_ratings.create(
|
||||
UserRatingCreate(
|
||||
user_id=id,
|
||||
recipe_id=recipe.id,
|
||||
rating=data.rating,
|
||||
is_favorite=data.is_favorite or False,
|
||||
)
|
||||
)
|
||||
else:
|
||||
if data.rating is not None:
|
||||
user_rating.rating = data.rating
|
||||
if data.is_favorite is not None:
|
||||
user_rating.is_favorite = data.is_favorite
|
||||
|
||||
self.repos.user_ratings.update(user_rating.id, user_rating)
|
||||
|
||||
@router.post("/{id}/favorites/{slug}")
|
||||
def add_favorite(self, id: UUID4, slug: str):
|
||||
"""Adds a recipe to the user's favorites"""
|
||||
self.set_rating(id, slug, data=UserRatingUpdate(is_favorite=True))
|
||||
|
||||
@router.delete("/{id}/favorites/{slug}")
|
||||
def remove_favorite(self, id: UUID4, slug: str):
|
||||
"""Removes a recipe from the user's favorites"""
|
||||
self.set_rating(id, slug, data=UserRatingUpdate(is_favorite=False))
|
||||
@@ -1,8 +1,13 @@
|
||||
# This file is auto-generated by gen_schema_exports.py
|
||||
from .datetime_parse import DateError, DateTimeError, DurationError, TimeError
|
||||
from .mealie_model import HasUUID, MealieModel, SearchType
|
||||
|
||||
__all__ = [
|
||||
"HasUUID",
|
||||
"MealieModel",
|
||||
"SearchType",
|
||||
"DateError",
|
||||
"DateTimeError",
|
||||
"DurationError",
|
||||
"TimeError",
|
||||
]
|
||||
|
||||
@@ -17,26 +17,9 @@ from .restore import (
|
||||
from .settings import CustomPageBase, CustomPageOut
|
||||
|
||||
__all__ = [
|
||||
"AllBackups",
|
||||
"BackupFile",
|
||||
"BackupOptions",
|
||||
"CreateBackup",
|
||||
"ImportJob",
|
||||
"EmailReady",
|
||||
"EmailSuccess",
|
||||
"EmailTest",
|
||||
"CustomPageBase",
|
||||
"CustomPageOut",
|
||||
"MaintenanceLogs",
|
||||
"MaintenanceStorageDetails",
|
||||
"MaintenanceSummary",
|
||||
"AdminAboutInfo",
|
||||
"AppInfo",
|
||||
"AppStartupInfo",
|
||||
"AppStatistics",
|
||||
"AppTheme",
|
||||
"CheckAppConfig",
|
||||
"OIDCInfo",
|
||||
"CommentImport",
|
||||
"CustomPageImport",
|
||||
"GroupImport",
|
||||
@@ -45,8 +28,25 @@ __all__ = [
|
||||
"RecipeImport",
|
||||
"SettingsImport",
|
||||
"UserImport",
|
||||
"EmailReady",
|
||||
"EmailSuccess",
|
||||
"EmailTest",
|
||||
"CustomPageBase",
|
||||
"CustomPageOut",
|
||||
"AdminAboutInfo",
|
||||
"AppInfo",
|
||||
"AppStartupInfo",
|
||||
"AppStatistics",
|
||||
"AppTheme",
|
||||
"CheckAppConfig",
|
||||
"OIDCInfo",
|
||||
"ChowdownURL",
|
||||
"MigrationFile",
|
||||
"MigrationImport",
|
||||
"Migrations",
|
||||
"AllBackups",
|
||||
"BackupFile",
|
||||
"BackupOptions",
|
||||
"CreateBackup",
|
||||
"ImportJob",
|
||||
]
|
||||
|
||||
@@ -45,36 +45,6 @@ from .invite_token import CreateInviteToken, EmailInitationResponse, EmailInvita
|
||||
from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType
|
||||
|
||||
__all__ = [
|
||||
"CreateWebhook",
|
||||
"ReadWebhook",
|
||||
"SaveWebhook",
|
||||
"WebhookPagination",
|
||||
"WebhookType",
|
||||
"GroupDataExport",
|
||||
"GroupEventNotifierCreate",
|
||||
"GroupEventNotifierOptions",
|
||||
"GroupEventNotifierOptionsOut",
|
||||
"GroupEventNotifierOptionsSave",
|
||||
"GroupEventNotifierOut",
|
||||
"GroupEventNotifierPrivate",
|
||||
"GroupEventNotifierSave",
|
||||
"GroupEventNotifierUpdate",
|
||||
"GroupEventPagination",
|
||||
"CreateGroupPreferences",
|
||||
"ReadGroupPreferences",
|
||||
"UpdateGroupPreferences",
|
||||
"GroupStatistics",
|
||||
"GroupStorage",
|
||||
"GroupAdminUpdate",
|
||||
"DataMigrationCreate",
|
||||
"SupportedMigrations",
|
||||
"SeederConfig",
|
||||
"SetPermissions",
|
||||
"CreateInviteToken",
|
||||
"EmailInitationResponse",
|
||||
"EmailInvitation",
|
||||
"ReadInviteToken",
|
||||
"SaveInviteToken",
|
||||
"ShoppingListAddRecipeParams",
|
||||
"ShoppingListCreate",
|
||||
"ShoppingListItemBase",
|
||||
@@ -97,4 +67,34 @@ __all__ = [
|
||||
"ShoppingListSave",
|
||||
"ShoppingListSummary",
|
||||
"ShoppingListUpdate",
|
||||
"CreateWebhook",
|
||||
"ReadWebhook",
|
||||
"SaveWebhook",
|
||||
"WebhookPagination",
|
||||
"WebhookType",
|
||||
"GroupAdminUpdate",
|
||||
"CreateGroupPreferences",
|
||||
"ReadGroupPreferences",
|
||||
"UpdateGroupPreferences",
|
||||
"SetPermissions",
|
||||
"DataMigrationCreate",
|
||||
"SupportedMigrations",
|
||||
"SeederConfig",
|
||||
"GroupDataExport",
|
||||
"CreateInviteToken",
|
||||
"EmailInitationResponse",
|
||||
"EmailInvitation",
|
||||
"ReadInviteToken",
|
||||
"SaveInviteToken",
|
||||
"GroupStatistics",
|
||||
"GroupStorage",
|
||||
"GroupEventNotifierCreate",
|
||||
"GroupEventNotifierOptions",
|
||||
"GroupEventNotifierOptionsOut",
|
||||
"GroupEventNotifierOptionsSave",
|
||||
"GroupEventNotifierOut",
|
||||
"GroupEventNotifierPrivate",
|
||||
"GroupEventNotifierSave",
|
||||
"GroupEventNotifierUpdate",
|
||||
"GroupEventPagination",
|
||||
]
|
||||
|
||||
@@ -30,6 +30,9 @@ __all__ = [
|
||||
"PlanRulesSave",
|
||||
"PlanRulesType",
|
||||
"Tag",
|
||||
"ListItem",
|
||||
"ShoppingListIn",
|
||||
"ShoppingListOut",
|
||||
"CreatePlanEntry",
|
||||
"CreateRandomEntry",
|
||||
"PlanEntryPagination",
|
||||
@@ -37,9 +40,6 @@ __all__ = [
|
||||
"ReadPlanEntry",
|
||||
"SavePlanEntry",
|
||||
"UpdatePlanEntry",
|
||||
"ListItem",
|
||||
"ShoppingListIn",
|
||||
"ShoppingListOut",
|
||||
"MealDayIn",
|
||||
"MealDayOut",
|
||||
"MealIn",
|
||||
|
||||
@@ -88,8 +88,20 @@ from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, Re
|
||||
from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse
|
||||
|
||||
__all__ = [
|
||||
"Nutrition",
|
||||
"RecipeSettings",
|
||||
"RecipeToolCreate",
|
||||
"RecipeToolOut",
|
||||
"RecipeToolResponse",
|
||||
"RecipeToolSave",
|
||||
"CategoryBase",
|
||||
"CategoryIn",
|
||||
"CategoryOut",
|
||||
"CategorySave",
|
||||
"RecipeCategoryResponse",
|
||||
"RecipeTagResponse",
|
||||
"TagBase",
|
||||
"TagIn",
|
||||
"TagOut",
|
||||
"TagSave",
|
||||
"AssignCategories",
|
||||
"AssignSettings",
|
||||
"AssignTags",
|
||||
@@ -97,12 +109,34 @@ __all__ = [
|
||||
"ExportBase",
|
||||
"ExportRecipes",
|
||||
"ExportTypes",
|
||||
"RecipeNote",
|
||||
"RecipeDuplicate",
|
||||
"RecipeSlug",
|
||||
"RecipeZipTokenResponse",
|
||||
"SlugResponse",
|
||||
"UpdateImageResponse",
|
||||
"RecipeShareToken",
|
||||
"RecipeShareTokenCreate",
|
||||
"RecipeShareTokenSave",
|
||||
"RecipeShareTokenSummary",
|
||||
"ScrapeRecipe",
|
||||
"ScrapeRecipeTest",
|
||||
"RecipeCommentCreate",
|
||||
"RecipeCommentOut",
|
||||
"RecipeCommentPagination",
|
||||
"RecipeCommentSave",
|
||||
"RecipeCommentUpdate",
|
||||
"UserBase",
|
||||
"RecipeImageTypes",
|
||||
"CreateRecipe",
|
||||
"CreateRecipeBulk",
|
||||
"CreateRecipeByUrlBulk",
|
||||
"Recipe",
|
||||
"RecipeCategory",
|
||||
"RecipeCategoryPagination",
|
||||
"RecipeLastMade",
|
||||
"RecipePagination",
|
||||
"RecipeSummary",
|
||||
"RecipeTag",
|
||||
"RecipeTagPagination",
|
||||
"RecipeTool",
|
||||
"RecipeToolPagination",
|
||||
"IngredientReferences",
|
||||
"RecipeStep",
|
||||
"CreateIngredientFood",
|
||||
"CreateIngredientFoodAlias",
|
||||
"CreateIngredientUnit",
|
||||
@@ -125,16 +159,7 @@ __all__ = [
|
||||
"SaveIngredientFood",
|
||||
"SaveIngredientUnit",
|
||||
"UnitFoodBase",
|
||||
"ScrapeRecipe",
|
||||
"ScrapeRecipeTest",
|
||||
"RecipeImageTypes",
|
||||
"IngredientReferences",
|
||||
"RecipeStep",
|
||||
"RecipeAsset",
|
||||
"RecipeToolCreate",
|
||||
"RecipeToolOut",
|
||||
"RecipeToolResponse",
|
||||
"RecipeToolSave",
|
||||
"RecipeTimelineEventCreate",
|
||||
"RecipeTimelineEventIn",
|
||||
"RecipeTimelineEventOut",
|
||||
@@ -142,37 +167,12 @@ __all__ = [
|
||||
"RecipeTimelineEventUpdate",
|
||||
"TimelineEventImage",
|
||||
"TimelineEventType",
|
||||
"CreateRecipe",
|
||||
"CreateRecipeBulk",
|
||||
"CreateRecipeByUrlBulk",
|
||||
"Recipe",
|
||||
"RecipeCategory",
|
||||
"RecipeCategoryPagination",
|
||||
"RecipeLastMade",
|
||||
"RecipePagination",
|
||||
"RecipeSummary",
|
||||
"RecipeTag",
|
||||
"RecipeTagPagination",
|
||||
"RecipeTool",
|
||||
"RecipeToolPagination",
|
||||
"CategoryBase",
|
||||
"CategoryIn",
|
||||
"CategoryOut",
|
||||
"CategorySave",
|
||||
"RecipeCategoryResponse",
|
||||
"RecipeTagResponse",
|
||||
"TagBase",
|
||||
"TagIn",
|
||||
"TagOut",
|
||||
"TagSave",
|
||||
"RecipeCommentCreate",
|
||||
"RecipeCommentOut",
|
||||
"RecipeCommentPagination",
|
||||
"RecipeCommentSave",
|
||||
"RecipeCommentUpdate",
|
||||
"UserBase",
|
||||
"RecipeShareToken",
|
||||
"RecipeShareTokenCreate",
|
||||
"RecipeShareTokenSave",
|
||||
"RecipeShareTokenSummary",
|
||||
"RecipeDuplicate",
|
||||
"RecipeSlug",
|
||||
"RecipeZipTokenResponse",
|
||||
"SlugResponse",
|
||||
"UpdateImageResponse",
|
||||
"Nutrition",
|
||||
"RecipeSettings",
|
||||
"RecipeNote",
|
||||
]
|
||||
|
||||
@@ -99,7 +99,7 @@ class RecipeSummary(MealieModel):
|
||||
recipe_category: Annotated[list[RecipeCategory] | None, Field(validate_default=True)] | None = []
|
||||
tags: Annotated[list[RecipeTag] | None, Field(validate_default=True)] = []
|
||||
tools: list[RecipeTool] = []
|
||||
rating: int | None = None
|
||||
rating: float | None = None
|
||||
org_url: str | None = Field(None, alias="orgURL")
|
||||
|
||||
date_added: datetime.date | None = None
|
||||
|
||||
@@ -11,6 +11,8 @@ __all__ = [
|
||||
"QueryFilterComponent",
|
||||
"RelationalKeyword",
|
||||
"RelationalOperator",
|
||||
"SearchFilter",
|
||||
"ValidationResponse",
|
||||
"OrderByNullPosition",
|
||||
"OrderDirection",
|
||||
"PaginationBase",
|
||||
@@ -19,6 +21,4 @@ __all__ = [
|
||||
"ErrorResponse",
|
||||
"FileTokenResponse",
|
||||
"SuccessResponse",
|
||||
"ValidationResponse",
|
||||
"SearchFilter",
|
||||
]
|
||||
|
||||
@@ -14,11 +14,15 @@ from .user import (
|
||||
PrivateUser,
|
||||
UpdateGroup,
|
||||
UserBase,
|
||||
UserFavorites,
|
||||
UserIn,
|
||||
UserOut,
|
||||
UserPagination,
|
||||
UserRatingCreate,
|
||||
UserRatingOut,
|
||||
UserRatings,
|
||||
UserRatingSummary,
|
||||
UserSummary,
|
||||
UserSummaryPagination,
|
||||
)
|
||||
from .user_passwords import (
|
||||
ForgotPassword,
|
||||
@@ -55,9 +59,13 @@ __all__ = [
|
||||
"PrivateUser",
|
||||
"UpdateGroup",
|
||||
"UserBase",
|
||||
"UserFavorites",
|
||||
"UserIn",
|
||||
"UserOut",
|
||||
"UserPagination",
|
||||
"UserRatingCreate",
|
||||
"UserRatingOut",
|
||||
"UserRatingSummary",
|
||||
"UserRatings",
|
||||
"UserSummary",
|
||||
"UserSummaryPagination",
|
||||
]
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
from typing import Annotated, Any, Generic, TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import UUID4, ConfigDict, Field, StringConstraints, field_validator
|
||||
from pydantic import UUID4, BaseModel, ConfigDict, Field, StringConstraints, field_validator
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
from sqlalchemy.orm.interfaces import LoaderOption
|
||||
|
||||
@@ -13,13 +13,12 @@ from mealie.db.models.users.users import AuthMethod
|
||||
from mealie.schema._mealie import MealieModel
|
||||
from mealie.schema.group.group_preferences import ReadGroupPreferences
|
||||
from mealie.schema.group.webhook import CreateWebhook, ReadWebhook
|
||||
from mealie.schema.recipe import RecipeSummary
|
||||
from mealie.schema.response.pagination import PaginationBase
|
||||
|
||||
from ...db.models.group import Group
|
||||
from ...db.models.recipe import RecipeModel
|
||||
from ..recipe import CategoryBase
|
||||
|
||||
DataT = TypeVar("DataT", bound=BaseModel)
|
||||
DEFAULT_INTEGRATION_ID = "generic"
|
||||
settings = get_app_settings()
|
||||
|
||||
@@ -58,6 +57,38 @@ class GroupBase(MealieModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class UserRatingSummary(MealieModel):
|
||||
recipe_id: UUID4
|
||||
rating: float | None = None
|
||||
is_favorite: Annotated[bool, Field(validate_default=True)] = False
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@field_validator("is_favorite", mode="before")
|
||||
def convert_is_favorite(cls, v: Any) -> bool:
|
||||
if v is None:
|
||||
return False
|
||||
else:
|
||||
return v
|
||||
|
||||
|
||||
class UserRatingCreate(UserRatingSummary):
|
||||
user_id: UUID4
|
||||
|
||||
|
||||
class UserRatingUpdate(MealieModel):
|
||||
rating: float | None = None
|
||||
is_favorite: bool | None = None
|
||||
|
||||
|
||||
class UserRatingOut(UserRatingCreate):
|
||||
id: UUID4
|
||||
|
||||
|
||||
class UserRatings(BaseModel, Generic[DataT]):
|
||||
ratings: list[DataT]
|
||||
|
||||
|
||||
class UserBase(MealieModel):
|
||||
id: UUID4 | None = None
|
||||
username: str | None = None
|
||||
@@ -67,7 +98,6 @@ class UserBase(MealieModel):
|
||||
admin: bool = False
|
||||
group: str | None = None
|
||||
advanced: bool = False
|
||||
favorite_recipes: list[str] | None = []
|
||||
|
||||
can_invite: bool = False
|
||||
can_manage: bool = False
|
||||
@@ -107,7 +137,6 @@ class UserOut(UserBase):
|
||||
group_slug: str
|
||||
tokens: list[LongLiveTokenOut] | None = None
|
||||
cache_key: str
|
||||
favorite_recipes: Annotated[list[str], Field(validate_default=True)] = []
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@property
|
||||
@@ -116,27 +145,7 @@ class UserOut(UserBase):
|
||||
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [joinedload(User.group), joinedload(User.favorite_recipes), joinedload(User.tokens)]
|
||||
|
||||
@field_validator("favorite_recipes", mode="before")
|
||||
def convert_favorite_recipes_to_slugs(cls, v: Any):
|
||||
if not v:
|
||||
return []
|
||||
if not isinstance(v, list):
|
||||
return v
|
||||
|
||||
slugs: list[str] = []
|
||||
for recipe in v:
|
||||
if isinstance(recipe, str):
|
||||
slugs.append(recipe)
|
||||
else:
|
||||
try:
|
||||
slugs.append(recipe.slug)
|
||||
except AttributeError:
|
||||
# this isn't a list of recipes, so we quit early and let Pydantic's typical validation handle it
|
||||
return v
|
||||
|
||||
return slugs
|
||||
return [joinedload(User.group), joinedload(User.tokens)]
|
||||
|
||||
|
||||
class UserSummary(MealieModel):
|
||||
@@ -153,20 +162,6 @@ class UserSummaryPagination(PaginationBase):
|
||||
items: list[UserSummary]
|
||||
|
||||
|
||||
class UserFavorites(UserBase):
|
||||
favorite_recipes: list[RecipeSummary] = [] # type: ignore
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [
|
||||
joinedload(User.group),
|
||||
selectinload(User.favorite_recipes).joinedload(RecipeModel.recipe_category),
|
||||
selectinload(User.favorite_recipes).joinedload(RecipeModel.tags),
|
||||
selectinload(User.favorite_recipes).joinedload(RecipeModel.tools),
|
||||
]
|
||||
|
||||
|
||||
class PrivateUser(UserOut):
|
||||
password: str
|
||||
group_id: UUID4
|
||||
@@ -198,7 +193,7 @@ class PrivateUser(UserOut):
|
||||
|
||||
@classmethod
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [joinedload(User.group), selectinload(User.favorite_recipes), joinedload(User.tokens)]
|
||||
return [joinedload(User.group), joinedload(User.tokens)]
|
||||
|
||||
|
||||
class UpdateGroup(GroupBase):
|
||||
@@ -244,7 +239,6 @@ class GroupInDB(UpdateGroup):
|
||||
joinedload(Group.webhooks),
|
||||
joinedload(Group.preferences),
|
||||
selectinload(Group.users).joinedload(User.group),
|
||||
selectinload(Group.users).joinedload(User.favorite_recipes),
|
||||
selectinload(Group.users).joinedload(User.tokens),
|
||||
]
|
||||
|
||||
|
||||
@@ -39,6 +39,5 @@ class PrivatePasswordResetToken(SavePasswordResetToken):
|
||||
def loader_options(cls) -> list[LoaderOption]:
|
||||
return [
|
||||
selectinload(PasswordResetModel.user).joinedload(User.group),
|
||||
selectinload(PasswordResetModel.user).joinedload(User.favorite_recipes),
|
||||
selectinload(PasswordResetModel.user).joinedload(User.tokens),
|
||||
]
|
||||
|
||||
@@ -20,7 +20,7 @@ from mealie.schema.recipe.recipe_settings import RecipeSettings
|
||||
from mealie.schema.recipe.recipe_step import RecipeStep
|
||||
from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType
|
||||
from mealie.schema.recipe.request_helpers import RecipeDuplicate
|
||||
from mealie.schema.user.user import GroupInDB, PrivateUser
|
||||
from mealie.schema.user.user import GroupInDB, PrivateUser, UserRatingCreate
|
||||
from mealie.services._base_service import BaseService
|
||||
from mealie.services.recipe.recipe_data_service import RecipeDataService
|
||||
|
||||
@@ -145,8 +145,20 @@ class RecipeService(BaseService):
|
||||
else:
|
||||
data.settings = RecipeSettings()
|
||||
|
||||
rating_input = data.rating
|
||||
new_recipe = self.repos.recipes.create(data)
|
||||
|
||||
# convert rating into user rating
|
||||
if rating_input:
|
||||
self.repos.user_ratings.create(
|
||||
UserRatingCreate(
|
||||
user_id=self.user.id,
|
||||
recipe_id=new_recipe.id,
|
||||
rating=rating_input,
|
||||
is_favorite=False,
|
||||
)
|
||||
)
|
||||
|
||||
# create first timeline entry
|
||||
timeline_event_data = RecipeTimelineEventCreate(
|
||||
user_id=new_recipe.user_id,
|
||||
|
||||
Reference in New Issue
Block a user