From 2edd7f6bb5b2a4f9d0f62c0477e6aa4de6214909 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 18 Mar 2024 01:03:39 +0000 Subject: [PATCH 01/30] fix type declaration --- mealie/db/models/users/users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mealie/db/models/users/users.py b/mealie/db/models/users/users.py index 65d507d7c33..0acae29342f 100644 --- a/mealie/db/models/users/users.py +++ b/mealie/db/models/users/users.py @@ -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) From a1651b01e202f274bcc3d1999fc3538b2105464f Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 18 Mar 2024 01:56:04 +0000 Subject: [PATCH 02/30] remove unused param --- mealie/repos/repository_recipes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py index 7fbe99879fd..30c0d83ff19 100644 --- a/mealie/repos/repository_recipes.py +++ b/mealie/repos/repository_recipes.py @@ -331,7 +331,7 @@ def get_random(self, limit=1) -> list[Recipe]: ) return [self.schema.model_validate(x) for x in self.session.execute(stmt).scalars().all()] - def get_by_slug(self, group_id: UUID4, slug: str, limit=1) -> Recipe | None: + def get_by_slug(self, group_id: UUID4, slug: str) -> Recipe | None: stmt = select(RecipeModel).filter(RecipeModel.group_id == group_id, RecipeModel.slug == slug) dbrecipe = self.session.execute(stmt).scalars().one_or_none() if dbrecipe is None: From 5da50e7104e9da83460cd434353d90c5763ff584 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 18 Mar 2024 04:30:07 +0000 Subject: [PATCH 03/30] migrated favorites and recipes to new user_recipes table --- ..._migrate_favorites_and_ratings_to_user_.py | 229 ++++++++++++++++++ mealie/db/models/recipe/recipe.py | 12 +- mealie/db/models/users/__init__.py | 2 +- mealie/db/models/users/user_to_favorite.py | 12 - mealie/db/models/users/user_to_recipe.py | 34 +++ mealie/db/models/users/users.py | 8 +- 6 files changed, 274 insertions(+), 23 deletions(-) create mode 100644 alembic/versions/2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_.py delete mode 100644 mealie/db/models/users/user_to_favorite.py create mode 100644 mealie/db/models/users/user_to_recipe.py diff --git a/alembic/versions/2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_.py b/alembic/versions/2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_.py new file mode 100644 index 00000000000..db5199df999 --- /dev/null +++ b/alembic/versions/2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_.py @@ -0,0 +1,229 @@ +"""migrate favorites and ratings to user_ratings + +Revision ID: d7c6efd2de42 +Revises: 09aba125b57a +Create Date: 2024-03-18 02:28:15.896959 + +""" + +from datetime import datetime +from textwrap import dedent +from typing import Any +from uuid import uuid4 + +import sqlalchemy as sa +from sqlalchemy import orm + +import mealie.db.migration_types +from alembic import op + +# revision identifiers, used by Alembic. +revision = "d7c6efd2de42" +down_revision = "09aba125b57a" +branch_labels = None +depends_on = None + + +def is_postgres(): + return op.get_context().dialect.name == "postgresql" + + +def new_user_rating(user_id: Any, recipe_id: Any, rating: float | None = None, is_favorite: bool = False): + if is_postgres(): + id = str(uuid4()) + else: + id = "%.32x" % uuid4().int + + now = datetime.now().isoformat() + return { + "id": id, + "user_id": user_id, + "recipe_id": recipe_id, + "rating": rating, + "is_favorite": is_favorite, + "created_at": now, + "update_at": now, + } + + +def migrate_user_favorites_to_user_ratings(): + bind = op.get_bind() + session = orm.Session(bind=bind) + + with session: + user_ids_and_recipe_ids = session.execute(sa.text("SELECT user_id, recipe_id FROM users_to_favorites")).all() + rows = [ + new_user_rating(user_id, recipe_id, is_favorite=True) + for user_id, recipe_id in user_ids_and_recipe_ids + if user_id and recipe_id + ] + + if is_postgres(): + query = dedent( + """ + INSERT INTO users_to_recipes (id, user_id, recipe_id, rating, is_favorite, created_at, update_at) + VALUES (:id, :user_id, :recipe_id, :rating, :is_favorite, :created_at, :update_at) + ON CONFLICT DO NOTHING + """ + ) + else: + query = dedent( + """ + INSERT OR IGNORE INTO users_to_recipes + (id, user_id, recipe_id, rating, is_favorite, created_at, update_at) + VALUES (:id, :user_id, :recipe_id, :rating, :is_favorite, :created_at, :update_at) + """ + ) + + for row in rows: + session.execute(sa.text(query), row) + + +def migrate_group_to_user_ratings(group_id: Any): + bind = op.get_bind() + session = orm.Session(bind=bind) + + with session: + user_ids = ( + session.execute(sa.text("SELECT id FROM users WHERE group_id=:group_id").bindparams(group_id=group_id)) + .scalars() + .all() + ) + + recipe_ids_ratings = session.execute( + sa.text( + "SELECT id, rating FROM recipes WHERE group_id=:group_id AND rating > 0 AND rating IS NOT NULL" + ).bindparams(group_id=group_id) + ).all() + + # Convert recipe ratings to user ratings. Since we don't know who + # rated the recipe initially, we copy the rating to all users. + rows: list[dict] = [] + for recipe_id, rating in recipe_ids_ratings: + for user_id in user_ids: + rows.append(new_user_rating(user_id, recipe_id, rating, is_favorite=False)) + + if is_postgres(): + insert_query = dedent( + """ + INSERT INTO users_to_recipes (id, user_id, recipe_id, rating, is_favorite, created_at, update_at) + VALUES (:id, :user_id, :recipe_id, :rating, :is_favorite, :created_at, :update_at) + ON CONFLICT (user_id, recipe_id) DO NOTHING; + """ + ) + else: + insert_query = dedent( + """ + INSERT OR IGNORE INTO users_to_recipes + (id, user_id, recipe_id, rating, is_favorite, created_at, update_at) + VALUES (:id, :user_id, :recipe_id, :rating, :is_favorite, :created_at, :update_at); + """ + ) + + update_query = dedent( + """ + UPDATE users_to_recipes + SET rating = :rating, update_at = :update_at + WHERE user_id = :user_id AND recipe_id = :recipe_id; + """ + ) + + # Create new user ratings with is_favorite set to False + for row in rows: + session.execute(sa.text(insert_query), row) + + # Update existing user ratings with the correct rating + for row in rows: + session.execute(sa.text(update_query), row) + + +def migrate_to_user_ratings(): + migrate_user_favorites_to_user_ratings() + + bind = op.get_bind() + session = orm.Session(bind=bind) + + with session: + group_ids = session.execute(sa.text("SELECT id FROM groups")).scalars().all() + + for group_id in group_ids: + migrate_group_to_user_ratings(group_id) + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "users_to_recipes", + sa.Column("user_id", mealie.db.migration_types.GUID(), nullable=False), + sa.Column("recipe_id", mealie.db.migration_types.GUID(), nullable=False), + sa.Column("rating", sa.Float(), nullable=True), + sa.Column("is_favorite", sa.Boolean(), nullable=False), + sa.Column("id", sa.Integer(), 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_key"), + ) + op.create_index(op.f("ix_users_to_recipes_created_at"), "users_to_recipes", ["created_at"], unique=False) + op.create_index(op.f("ix_users_to_recipes_is_favorite"), "users_to_recipes", ["is_favorite"], unique=False) + op.create_index(op.f("ix_users_to_recipes_rating"), "users_to_recipes", ["rating"], unique=False) + op.create_index(op.f("ix_users_to_recipes_recipe_id"), "users_to_recipes", ["recipe_id"], unique=False) + op.create_index(op.f("ix_users_to_recipes_user_id"), "users_to_recipes", ["user_id"], unique=False) + + migrate_to_user_ratings() + + if is_postgres(): + op.drop_index("ix_users_to_favorites_recipe_id", table_name="users_to_favorites") + op.drop_index("ix_users_to_favorites_user_id", table_name="users_to_favorites") + op.alter_column("recipes", "rating", existing_type=sa.INTEGER(), type_=sa.Float(), existing_nullable=True) + else: + op.execute("DROP INDEX IF EXISTS ix_users_to_favorites_recipe_id") + op.execute("DROP INDEX IF EXISTS ix_users_to_favorites_user_id") + with op.batch_alter_table("recipes") as batch_op: + batch_op.alter_column("rating", existing_type=sa.INTEGER(), type_=sa.Float(), existing_nullable=True) + + op.drop_table("users_to_favorites") + op.create_index(op.f("ix_recipes_rating"), "recipes", ["rating"], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "recipes_ingredients", "quantity", existing_type=sa.Float(), type_=sa.INTEGER(), existing_nullable=True + ) + op.drop_index(op.f("ix_recipes_rating"), table_name="recipes") + op.alter_column("recipes", "rating", existing_type=sa.Float(), type_=sa.INTEGER(), existing_nullable=True) + op.create_unique_constraint("ingredient_units_name_group_id_key", "ingredient_units", ["name", "group_id"]) + op.create_unique_constraint("ingredient_foods_name_group_id_key", "ingredient_foods", ["name", "group_id"]) + op.create_table( + "users_to_favorites", + sa.Column("user_id", sa.CHAR(length=32), nullable=True), + sa.Column("recipe_id", sa.CHAR(length=32), nullable=True), + sa.ForeignKeyConstraint( + ["recipe_id"], + ["recipes.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.UniqueConstraint("user_id", "recipe_id", name="user_id_recipe_id_key"), + ) + op.create_index("ix_users_to_favorites_user_id", "users_to_favorites", ["user_id"], unique=False) + op.create_index("ix_users_to_favorites_recipe_id", "users_to_favorites", ["recipe_id"], unique=False) + op.drop_index(op.f("ix_users_to_recipes_user_id"), table_name="users_to_recipes") + op.drop_index(op.f("ix_users_to_recipes_recipe_id"), table_name="users_to_recipes") + op.drop_index(op.f("ix_users_to_recipes_rating"), table_name="users_to_recipes") + op.drop_index(op.f("ix_users_to_recipes_is_favorite"), table_name="users_to_recipes") + op.drop_index(op.f("ix_users_to_recipes_created_at"), table_name="users_to_recipes") + op.drop_table("users_to_recipes") + # ### end Alembic commands ### diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index 63632a6e304..17df10753ce 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -12,7 +12,7 @@ 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 +49,13 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): user_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("users.id", use_alter=True), index=True) user: Mapped["User"] = orm.relationship("User", uselist=False, foreign_keys=[user_id]) - meal_entries: Mapped[list["GroupMealPlan"]] = orm.relationship( - "GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan" + rating: Mapped[float | None] = mapped_column(sa.Float, index=True, nullable=True) + rated_by: Mapped[list["User"]] = orm.relationship( + "User", secondary=UserToRecipe.__tablename__, back_populates="rated_recipes" ) - favorited_by: Mapped[list["User"]] = orm.relationship( - "User", secondary=users_to_favorites, back_populates="favorite_recipes" + meal_entries: Mapped[list["GroupMealPlan"]] = orm.relationship( + "GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan" ) # General Recipe Properties @@ -110,7 +111,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) diff --git a/mealie/db/models/users/__init__.py b/mealie/db/models/users/__init__.py index 586c7516a7c..9f181770f54 100644 --- a/mealie/db/models/users/__init__.py +++ b/mealie/db/models/users/__init__.py @@ -1,3 +1,3 @@ from .password_reset import * -from .user_to_favorite import * +from .user_to_recipe import * from .users import * diff --git a/mealie/db/models/users/user_to_favorite.py b/mealie/db/models/users/user_to_favorite.py deleted file mode 100644 index b838a6194b9..00000000000 --- a/mealie/db/models/users/user_to_favorite.py +++ /dev/null @@ -1,12 +0,0 @@ -from sqlalchemy import Column, ForeignKey, Table, UniqueConstraint - -from .._model_base import SqlAlchemyBase -from .._model_utils import GUID - -users_to_favorites = Table( - "users_to_favorites", - SqlAlchemyBase.metadata, - Column("user_id", GUID, ForeignKey("users.id"), index=True), - Column("recipe_id", GUID, ForeignKey("recipes.id"), index=True), - UniqueConstraint("user_id", "recipe_id", name="user_id_recipe_id_key"), -) diff --git a/mealie/db/models/users/user_to_recipe.py b/mealie/db/models/users/user_to_recipe.py new file mode 100644 index 00000000000..faaa454610d --- /dev/null +++ b/mealie/db/models/users/user_to_recipe.py @@ -0,0 +1,34 @@ +from sqlalchemy import Boolean, Column, Float, ForeignKey, UniqueConstraint, event, func +from sqlalchemy.engine.base import Connection +from sqlalchemy.orm.session import Session + +from .._model_base import SqlAlchemyBase +from .._model_utils import GUID + + +class UserToRecipe(SqlAlchemyBase): + __tablename__ = "users_to_recipes" + __table_args__ = (UniqueConstraint("user_id", "recipe_id", name="user_id_recipe_id_key"),) + + 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) + + +@event.listens_for(UserToRecipe, "after_insert") +@event.listens_for(UserToRecipe, "after_delete") +@event.listens_for(UserToRecipe.rating, "set") +def update_recipe_rating(connection: Connection, target: UserToRecipe): + from mealie.db.models.recipe.recipe import RecipeModel + + with Session(bind=connection) as session: + recipe_id = target.recipe_id + recipe = session.query(RecipeModel).filter(RecipeModel.id == target.recipe_id).first() + if not recipe: + return + + recipe.rating = ( + session.query(func.avg(UserToRecipe.rating)).filter(UserToRecipe.recipe_id == recipe_id).scalar() + ) + session.commit() diff --git a/mealie/db/models/users/users.py b/mealie/db/models/users/users.py index 0acae29342f..6120a88e829 100644 --- a/mealie/db/models/users/users.py +++ b/mealie/db/models/users/users.py @@ -12,7 +12,7 @@ from .._model_base import BaseMixins, SqlAlchemyBase from .._model_utils import auto_init -from .user_to_favorite import users_to_favorites +from .user_to_recipe import UserToRecipe if TYPE_CHECKING: from ..group import Group @@ -84,8 +84,8 @@ class User(SqlAlchemyBase, BaseMixins): "GroupMealPlan", order_by="GroupMealPlan.date", **sp_args ) shopping_lists: Mapped[Optional["ShoppingList"]] = orm.relationship("ShoppingList", **sp_args) - favorite_recipes: Mapped[list["RecipeModel"]] = orm.relationship( - "RecipeModel", secondary=users_to_favorites, back_populates="favorited_by" + rated_recipes: Mapped[list["RecipeModel"]] = orm.relationship( + "RecipeModel", secondary=UserToRecipe.__tablename__, back_populates="rated_by" ) model_config = ConfigDict( exclude={ @@ -112,7 +112,7 @@ def __init__(self, session, full_name, password, group: str | None = None, **kwa self.group = Group.get_by_name(session, group) - self.favorite_recipes = [] + self.rated_recipes = [] self.password = password From 6efdf80a97c5e8ca396338261d33c242115138ee Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 18 Mar 2024 17:42:17 +0000 Subject: [PATCH 04/30] replaced favorite routes with user rating routes --- mealie/db/models/users/user_to_recipe.py | 47 +++++++++----- mealie/repos/repository_factory.py | 8 ++- mealie/repos/repository_users.py | 26 +++++++- mealie/routes/users/__init__.py | 4 +- mealie/routes/users/crud.py | 17 ++++- mealie/routes/users/favorites.py | 39 ------------ mealie/routes/users/ratings.py | 80 ++++++++++++++++++++++++ mealie/schema/recipe/recipe.py | 2 +- mealie/schema/user/__init__.py | 2 - mealie/schema/user/user.py | 74 ++++++++++------------ mealie/schema/user/user_passwords.py | 1 - 11 files changed, 194 insertions(+), 106 deletions(-) delete mode 100644 mealie/routes/users/favorites.py create mode 100644 mealie/routes/users/ratings.py diff --git a/mealie/db/models/users/user_to_recipe.py b/mealie/db/models/users/user_to_recipe.py index faaa454610d..23bdbd622cc 100644 --- a/mealie/db/models/users/user_to_recipe.py +++ b/mealie/db/models/users/user_to_recipe.py @@ -1,12 +1,12 @@ from sqlalchemy import Boolean, Column, Float, ForeignKey, UniqueConstraint, event, func from sqlalchemy.engine.base import Connection -from sqlalchemy.orm.session import Session +from sqlalchemy.orm.session import Session, object_session -from .._model_base import SqlAlchemyBase -from .._model_utils import GUID +from .._model_base import BaseMixins, SqlAlchemyBase +from .._model_utils import GUID, auto_init -class UserToRecipe(SqlAlchemyBase): +class UserToRecipe(SqlAlchemyBase, BaseMixins): __tablename__ = "users_to_recipes" __table_args__ = (UniqueConstraint("user_id", "recipe_id", name="user_id_recipe_id_key"),) @@ -15,20 +15,35 @@ class UserToRecipe(SqlAlchemyBase): 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_id = target.recipe_id + recipe = session.query(RecipeModel).filter(RecipeModel.id == target.recipe_id).first() + if not recipe: + return + + recipe.rating = session.query(func.avg(UserToRecipe.rating)).filter(UserToRecipe.recipe_id == recipe_id).scalar() + @event.listens_for(UserToRecipe, "after_insert") @event.listens_for(UserToRecipe, "after_delete") -@event.listens_for(UserToRecipe.rating, "set") -def update_recipe_rating(connection: Connection, target: UserToRecipe): - from mealie.db.models.recipe.recipe import RecipeModel +def update_recipe_rating_on_insert_or_delete(_, connection: Connection, target: UserToRecipe): + session = Session(bind=connection) + update_recipe_rating(session, target) + session.commit() - with Session(bind=connection) as session: - recipe_id = target.recipe_id - recipe = session.query(RecipeModel).filter(RecipeModel.id == target.recipe_id).first() - if not recipe: - return - recipe.rating = ( - session.query(func.avg(UserToRecipe.rating)).filter(UserToRecipe.recipe_id == recipe_id).scalar() - ) - session.commit() +@event.listens_for(UserToRecipe.rating, "set") +def update_recipe_rating_on_update(target: UserToRecipe, *_): + session = object_session(target) + if not session: + return + + update_recipe_rating(session, target) + session.commit() diff --git a/mealie/repos/repository_factory.py b/mealie/repos/repository_factory.py index f0a07d9ca19..701de158726 100644 --- a/mealie/repos/repository_factory.py +++ b/mealie/repos/repository_factory.py @@ -31,6 +31,7 @@ from mealie.db.models.server.task import ServerTaskModel from mealie.db.models.users import LongLiveToken, User from mealie.db.models.users.password_reset import PasswordResetModel +from mealie.db.models.users.user_to_recipe import UserToRecipe from mealie.repos.repository_foods import RepositoryFood from mealie.repos.repository_meal_plan_rules import RepositoryMealPlanRules from mealie.repos.repository_units import RepositoryUnit @@ -58,6 +59,7 @@ from mealie.schema.reports.reports import ReportEntryOut, ReportOut from mealie.schema.server import ServerTask from mealie.schema.user import GroupInDB, LongLiveTokenInDB, PrivateUser +from mealie.schema.user.user import UserRatingOut from mealie.schema.user.user_passwords import PrivatePasswordResetToken from .repository_generic import RepositoryGeneric @@ -65,7 +67,7 @@ from .repository_meals import RepositoryMeals from .repository_recipes import RepositoryRecipes from .repository_shopping_list import RepositoryShoppingList -from .repository_users import RepositoryUsers +from .repository_users import RepositoryUserRatings, RepositoryUsers PK_ID = "id" PK_SLUG = "slug" @@ -143,6 +145,10 @@ def recipe_timeline_events(self) -> RepositoryGeneric[RecipeTimelineEventOut, Re def users(self) -> RepositoryUsers: return RepositoryUsers(self.session, PK_ID, User, PrivateUser) + @cached_property + def user_ratings(self) -> RepositoryUserRatings: + return RepositoryUserRatings(self.session, PK_ID, UserToRecipe, UserRatingOut) + @cached_property def api_tokens(self) -> RepositoryGeneric[LongLiveTokenInDB, LongLiveToken]: return RepositoryGeneric(self.session, PK_ID, LongLiveToken, LongLiveTokenInDB) diff --git a/mealie/repos/repository_users.py b/mealie/repos/repository_users.py index fafd2060b2d..989eb722e0e 100644 --- a/mealie/repos/repository_users.py +++ b/mealie/repos/repository_users.py @@ -6,7 +6,8 @@ from mealie.assets import users as users_assets from mealie.core.config import get_app_settings -from mealie.schema.user.user import PrivateUser +from mealie.db.models.users.user_to_recipe import UserToRecipe +from mealie.schema.user.user import PrivateUser, UserRatingOut from ..db.models.users import User from .repository_generic import RepositoryGeneric @@ -72,3 +73,26 @@ def get_locked_users(self) -> list[PrivateUser]: stmt = select(User).filter(User.locked_at != None) # noqa E711 results = self.session.execute(stmt).scalars().all() return [self.schema.model_validate(x) for x in results] + + +class RepositoryUserRatings(RepositoryGeneric[UserRatingOut, UserToRecipe]): + def get_by_user(self, user_id: UUID4, favorites_only=False) -> list[UserRatingOut]: + stmt = select(UserToRecipe).filter(UserToRecipe.user_id == user_id) + if favorites_only: + stmt = stmt.filter(UserToRecipe.is_favorite == True) + + 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 == True) + + results = self.session.execute(stmt).scalars().all() + return [self.schema.model_validate(x) for x in results] + + def get_by_user_and_recipe(self, user_id: UUID4, recipe_id: UUID4) -> UserRatingOut | None: + stmt = select(UserToRecipe).filter(UserToRecipe.user_id == user_id, UserToRecipe.recipe_id == recipe_id) + result = self.session.execute(stmt).scalars().one_or_none() + return None if result is None else self.schema.model_validate(result) diff --git a/mealie/routes/users/__init__.py b/mealie/routes/users/__init__.py index aec44e11219..f46c8a54ac0 100644 --- a/mealie/routes/users/__init__.py +++ b/mealie/routes/users/__init__.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from . import api_tokens, crud, favorites, forgot_password, images, registration +from . import api_tokens, crud, forgot_password, images, ratings, registration # Must be used because of the way FastAPI works with nested routes user_prefix = "/users" @@ -13,4 +13,4 @@ router.include_router(forgot_password.router, prefix=user_prefix, tags=["Users: Passwords"]) router.include_router(images.router, prefix=user_prefix, tags=["Users: Images"]) router.include_router(api_tokens.router) -router.include_router(favorites.router, prefix=user_prefix, tags=["Users: Favorites"]) +router.include_router(ratings.router, prefix=user_prefix, tags=["Users: Ratings"]) diff --git a/mealie/routes/users/crud.py b/mealie/routes/users/crud.py index 27aa8f95de3..69142084325 100644 --- a/mealie/routes/users/crud.py +++ b/mealie/routes/users/crud.py @@ -11,7 +11,14 @@ from mealie.schema.response import ErrorResponse, SuccessResponse from mealie.schema.response.pagination import PaginationQuery from mealie.schema.user import ChangePassword, UserBase, UserIn, UserOut -from mealie.schema.user.user import GroupInDB, UserPagination, UserSummary, UserSummaryPagination +from mealie.schema.user.user import ( + GroupInDB, + UserPagination, + UserRatings, + UserRatingSummary, + UserSummary, + UserSummaryPagination, +) user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"]) admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"]) @@ -74,6 +81,14 @@ def get_all_group_users(self, q: PaginationQuery = Depends(PaginationQuery)): def get_logged_in_user(self): return self.user + @user_router.get("/self/ratings", response_model=UserRatings[UserRatingSummary]) + def get_logged_in_user_ratings(self): + return UserRatings(ratings=self.repos.user_ratings.get_by_user(self.user.id)) + + @user_router.get("/self/favorites", response_model=UserRatings[UserRatingSummary]) + def get_logged_in_user_favorites(self): + return UserRatings(ratings=self.repos.user_ratings.get_by_user(self.user.id, favorites_only=True)) + @user_router.get("/self/group", response_model=GroupInDB) def get_logged_in_user_group(self): return self.group diff --git a/mealie/routes/users/favorites.py b/mealie/routes/users/favorites.py deleted file mode 100644 index 14f06075466..00000000000 --- a/mealie/routes/users/favorites.py +++ /dev/null @@ -1,39 +0,0 @@ -from pydantic import UUID4 - -from mealie.routes._base import BaseUserController, controller -from mealie.routes._base.routers import UserAPIRouter -from mealie.routes.users._helpers import assert_user_change_allowed -from mealie.schema.user import UserFavorites - -router = UserAPIRouter() - - -@controller(router) -class UserFavoritesController(BaseUserController): - @router.get("/{id}/favorites", response_model=UserFavorites) - async def get_favorites(self, id: UUID4): - """Get user's favorite recipes""" - return self.repos.users.get_one(id, override_schema=UserFavorites) - - @router.post("/{id}/favorites/{slug}") - def add_favorite(self, id: UUID4, slug: str): - """Adds a Recipe to the users favorites""" - assert_user_change_allowed(id, self.user) - - if not self.user.favorite_recipes: - self.user.favorite_recipes = [] - - self.user.favorite_recipes.append(slug) - self.repos.users.update(self.user.id, self.user) - - @router.delete("/{id}/favorites/{slug}") - def remove_favorite(self, id: UUID4, slug: str): - """Adds a Recipe to the users favorites""" - assert_user_change_allowed(id, self.user) - - if not self.user.favorite_recipes: - self.user.favorite_recipes = [] - - self.user.favorite_recipes = [x for x in self.user.favorite_recipes if x != slug] - self.repos.users.update(self.user.id, self.user) - return diff --git a/mealie/routes/users/ratings.py b/mealie/routes/users/ratings.py new file mode 100644 index 00000000000..d1c23f9959e --- /dev/null +++ b/mealie/routes/users/ratings.py @@ -0,0 +1,80 @@ +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 + +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 + + if isinstance(slug_or_id, UUID): + recipe = self.repos.recipes.get_one(slug_or_id, key="id") + else: + recipe = self.repos.recipes.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, rating: float | None = None, is_favorite: bool | None = None): + """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=rating, + is_favorite=is_favorite or False, + ) + ) + else: + if rating is not None: + user_rating.rating = rating + if is_favorite is not None: + user_rating.is_favorite = 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, is_favorite=True) + + @router.delete("/{id}/favorites/{slug}") + def remove_favorite(self, id: UUID4, slug: str): + """Adds a Recipe to the user's favorites""" + self.set_rating(id, slug, is_favorite=False) diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index e0914ed080a..8120ba53fbe 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -99,7 +99,7 @@ class RecipeSummary(MealieModel): recipe_category: Annotated[list[RecipeCategory] | None, Field(validate_default=True)] | None = [] tags: Annotated[list[RecipeTag] | None, Field(validate_default=True)] = [] tools: list[RecipeTool] = [] - rating: int | None = None + rating: float | None = None org_url: str | None = Field(None, alias="orgURL") date_added: datetime.date | None = None diff --git a/mealie/schema/user/__init__.py b/mealie/schema/user/__init__.py index a9524787c39..fe91630917c 100644 --- a/mealie/schema/user/__init__.py +++ b/mealie/schema/user/__init__.py @@ -14,7 +14,6 @@ PrivateUser, UpdateGroup, UserBase, - UserFavorites, UserIn, UserOut, UserPagination, @@ -55,7 +54,6 @@ "PrivateUser", "UpdateGroup", "UserBase", - "UserFavorites", "UserIn", "UserOut", "UserPagination", diff --git a/mealie/schema/user/user.py b/mealie/schema/user/user.py index 864ff6bd5ed..6446c4af04d 100644 --- a/mealie/schema/user/user.py +++ b/mealie/schema/user/user.py @@ -1,9 +1,9 @@ from datetime import datetime, timedelta from pathlib import Path -from typing import Annotated, Any +from typing import Annotated, Any, Generic, TypeVar from uuid import UUID -from pydantic import UUID4, ConfigDict, Field, StringConstraints, field_validator +from pydantic import UUID4, BaseModel, ConfigDict, Field, StringConstraints, field_validator from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm.interfaces import LoaderOption @@ -13,13 +13,13 @@ 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 +58,33 @@ 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 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 +94,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 +133,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 +141,7 @@ def is_default_user(self) -> bool: @classmethod def loader_options(cls) -> list[LoaderOption]: - return [joinedload(User.group), joinedload(User.favorite_recipes), joinedload(User.tokens)] - - @field_validator("favorite_recipes", mode="before") - def convert_favorite_recipes_to_slugs(cls, v: Any): - if not v: - return [] - if not isinstance(v, list): - return v - - slugs: list[str] = [] - for recipe in v: - if isinstance(recipe, str): - slugs.append(recipe) - else: - try: - slugs.append(recipe.slug) - except AttributeError: - # this isn't a list of recipes, so we quit early and let Pydantic's typical validation handle it - return v - - return slugs + return [joinedload(User.group), joinedload(User.tokens)] class UserSummary(MealieModel): @@ -153,20 +158,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 +189,7 @@ def directory(self) -> Path: @classmethod def loader_options(cls) -> list[LoaderOption]: - return [joinedload(User.group), selectinload(User.favorite_recipes), joinedload(User.tokens)] + return [joinedload(User.group), joinedload(User.tokens)] class UpdateGroup(GroupBase): @@ -244,7 +235,6 @@ def loader_options(cls) -> list[LoaderOption]: joinedload(Group.webhooks), joinedload(Group.preferences), selectinload(Group.users).joinedload(User.group), - selectinload(Group.users).joinedload(User.favorite_recipes), selectinload(Group.users).joinedload(User.tokens), ] diff --git a/mealie/schema/user/user_passwords.py b/mealie/schema/user/user_passwords.py index 3e10945eeb1..eb407b7d929 100644 --- a/mealie/schema/user/user_passwords.py +++ b/mealie/schema/user/user_passwords.py @@ -39,6 +39,5 @@ class PrivatePasswordResetToken(SavePasswordResetToken): def loader_options(cls) -> list[LoaderOption]: return [ selectinload(PasswordResetModel.user).joinedload(User.group), - selectinload(PasswordResetModel.user).joinedload(User.favorite_recipes), selectinload(PasswordResetModel.user).joinedload(User.tokens), ] From 3709f5df12c196d908197cb65f58982306053031 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 18 Mar 2024 17:44:59 +0000 Subject: [PATCH 05/30] partial generation --- mealie/schema/_mealie/__init__.py | 5 ++ mealie/schema/admin/__init__.py | 32 ++++----- mealie/schema/group/__init__.py | 60 ++++++++--------- mealie/schema/meal_plan/__init__.py | 6 +- mealie/schema/recipe/__init__.py | 100 ++++++++++++++-------------- mealie/schema/response/__init__.py | 4 +- mealie/schema/user/__init__.py | 10 +++ tests/utils/api_routes/__init__.py | 14 ++++ 8 files changed, 130 insertions(+), 101 deletions(-) diff --git a/mealie/schema/_mealie/__init__.py b/mealie/schema/_mealie/__init__.py index 6712561a6cc..7441e747a51 100644 --- a/mealie/schema/_mealie/__init__.py +++ b/mealie/schema/_mealie/__init__.py @@ -1,8 +1,13 @@ # This file is auto-generated by gen_schema_exports.py +from .datetime_parse import DateError, DateTimeError, DurationError, TimeError from .mealie_model import HasUUID, MealieModel, SearchType __all__ = [ "HasUUID", "MealieModel", "SearchType", + "DateError", + "DateTimeError", + "DurationError", + "TimeError", ] diff --git a/mealie/schema/admin/__init__.py b/mealie/schema/admin/__init__.py index b2bf2b3288c..5e255d7bb98 100644 --- a/mealie/schema/admin/__init__.py +++ b/mealie/schema/admin/__init__.py @@ -17,19 +17,22 @@ from .settings import CustomPageBase, CustomPageOut __all__ = [ - "AllBackups", - "BackupFile", - "BackupOptions", - "CreateBackup", - "ImportJob", + "MaintenanceLogs", + "MaintenanceStorageDetails", + "MaintenanceSummary", + "CommentImport", + "CustomPageImport", + "GroupImport", + "ImportBase", + "NotificationImport", + "RecipeImport", + "SettingsImport", + "UserImport", "EmailReady", "EmailSuccess", "EmailTest", "CustomPageBase", "CustomPageOut", - "MaintenanceLogs", - "MaintenanceStorageDetails", - "MaintenanceSummary", "AdminAboutInfo", "AppInfo", "AppStartupInfo", @@ -37,16 +40,13 @@ "AppTheme", "CheckAppConfig", "OIDCInfo", - "CommentImport", - "CustomPageImport", - "GroupImport", - "ImportBase", - "NotificationImport", - "RecipeImport", - "SettingsImport", - "UserImport", "ChowdownURL", "MigrationFile", "MigrationImport", "Migrations", + "AllBackups", + "BackupFile", + "BackupOptions", + "CreateBackup", + "ImportJob", ] diff --git a/mealie/schema/group/__init__.py b/mealie/schema/group/__init__.py index 13f0ff8bfcc..7dce2767c9c 100644 --- a/mealie/schema/group/__init__.py +++ b/mealie/schema/group/__init__.py @@ -45,36 +45,6 @@ from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType __all__ = [ - "CreateWebhook", - "ReadWebhook", - "SaveWebhook", - "WebhookPagination", - "WebhookType", - "GroupDataExport", - "GroupEventNotifierCreate", - "GroupEventNotifierOptions", - "GroupEventNotifierOptionsOut", - "GroupEventNotifierOptionsSave", - "GroupEventNotifierOut", - "GroupEventNotifierPrivate", - "GroupEventNotifierSave", - "GroupEventNotifierUpdate", - "GroupEventPagination", - "CreateGroupPreferences", - "ReadGroupPreferences", - "UpdateGroupPreferences", - "GroupStatistics", - "GroupStorage", - "GroupAdminUpdate", - "DataMigrationCreate", - "SupportedMigrations", - "SeederConfig", - "SetPermissions", - "CreateInviteToken", - "EmailInitationResponse", - "EmailInvitation", - "ReadInviteToken", - "SaveInviteToken", "ShoppingListAddRecipeParams", "ShoppingListCreate", "ShoppingListItemBase", @@ -97,4 +67,34 @@ "ShoppingListSave", "ShoppingListSummary", "ShoppingListUpdate", + "CreateWebhook", + "ReadWebhook", + "SaveWebhook", + "WebhookPagination", + "WebhookType", + "GroupAdminUpdate", + "CreateGroupPreferences", + "ReadGroupPreferences", + "UpdateGroupPreferences", + "SetPermissions", + "DataMigrationCreate", + "SupportedMigrations", + "SeederConfig", + "GroupDataExport", + "CreateInviteToken", + "EmailInitationResponse", + "EmailInvitation", + "ReadInviteToken", + "SaveInviteToken", + "GroupStatistics", + "GroupStorage", + "GroupEventNotifierCreate", + "GroupEventNotifierOptions", + "GroupEventNotifierOptionsOut", + "GroupEventNotifierOptionsSave", + "GroupEventNotifierOut", + "GroupEventNotifierPrivate", + "GroupEventNotifierSave", + "GroupEventNotifierUpdate", + "GroupEventPagination", ] diff --git a/mealie/schema/meal_plan/__init__.py b/mealie/schema/meal_plan/__init__.py index d99fc75c65d..3f757943c65 100644 --- a/mealie/schema/meal_plan/__init__.py +++ b/mealie/schema/meal_plan/__init__.py @@ -30,6 +30,9 @@ "PlanRulesSave", "PlanRulesType", "Tag", + "ListItem", + "ShoppingListIn", + "ShoppingListOut", "CreatePlanEntry", "CreateRandomEntry", "PlanEntryPagination", @@ -37,9 +40,6 @@ "ReadPlanEntry", "SavePlanEntry", "UpdatePlanEntry", - "ListItem", - "ShoppingListIn", - "ShoppingListOut", "MealDayIn", "MealDayOut", "MealIn", diff --git a/mealie/schema/recipe/__init__.py b/mealie/schema/recipe/__init__.py index b2efd25d58a..bff38c44961 100644 --- a/mealie/schema/recipe/__init__.py +++ b/mealie/schema/recipe/__init__.py @@ -88,8 +88,20 @@ from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse __all__ = [ - "Nutrition", - "RecipeSettings", + "RecipeToolCreate", + "RecipeToolOut", + "RecipeToolResponse", + "RecipeToolSave", + "CategoryBase", + "CategoryIn", + "CategoryOut", + "CategorySave", + "RecipeCategoryResponse", + "RecipeTagResponse", + "TagBase", + "TagIn", + "TagOut", + "TagSave", "AssignCategories", "AssignSettings", "AssignTags", @@ -97,12 +109,34 @@ "ExportBase", "ExportRecipes", "ExportTypes", - "RecipeNote", - "RecipeDuplicate", - "RecipeSlug", - "RecipeZipTokenResponse", - "SlugResponse", - "UpdateImageResponse", + "RecipeShareToken", + "RecipeShareTokenCreate", + "RecipeShareTokenSave", + "RecipeShareTokenSummary", + "ScrapeRecipe", + "ScrapeRecipeTest", + "RecipeCommentCreate", + "RecipeCommentOut", + "RecipeCommentPagination", + "RecipeCommentSave", + "RecipeCommentUpdate", + "UserBase", + "RecipeImageTypes", + "CreateRecipe", + "CreateRecipeBulk", + "CreateRecipeByUrlBulk", + "Recipe", + "RecipeCategory", + "RecipeCategoryPagination", + "RecipeLastMade", + "RecipePagination", + "RecipeSummary", + "RecipeTag", + "RecipeTagPagination", + "RecipeTool", + "RecipeToolPagination", + "IngredientReferences", + "RecipeStep", "CreateIngredientFood", "CreateIngredientFoodAlias", "CreateIngredientUnit", @@ -125,16 +159,7 @@ "SaveIngredientFood", "SaveIngredientUnit", "UnitFoodBase", - "ScrapeRecipe", - "ScrapeRecipeTest", - "RecipeImageTypes", - "IngredientReferences", - "RecipeStep", "RecipeAsset", - "RecipeToolCreate", - "RecipeToolOut", - "RecipeToolResponse", - "RecipeToolSave", "RecipeTimelineEventCreate", "RecipeTimelineEventIn", "RecipeTimelineEventOut", @@ -142,37 +167,12 @@ "RecipeTimelineEventUpdate", "TimelineEventImage", "TimelineEventType", - "CreateRecipe", - "CreateRecipeBulk", - "CreateRecipeByUrlBulk", - "Recipe", - "RecipeCategory", - "RecipeCategoryPagination", - "RecipeLastMade", - "RecipePagination", - "RecipeSummary", - "RecipeTag", - "RecipeTagPagination", - "RecipeTool", - "RecipeToolPagination", - "CategoryBase", - "CategoryIn", - "CategoryOut", - "CategorySave", - "RecipeCategoryResponse", - "RecipeTagResponse", - "TagBase", - "TagIn", - "TagOut", - "TagSave", - "RecipeCommentCreate", - "RecipeCommentOut", - "RecipeCommentPagination", - "RecipeCommentSave", - "RecipeCommentUpdate", - "UserBase", - "RecipeShareToken", - "RecipeShareTokenCreate", - "RecipeShareTokenSave", - "RecipeShareTokenSummary", + "RecipeDuplicate", + "RecipeSlug", + "RecipeZipTokenResponse", + "SlugResponse", + "UpdateImageResponse", + "Nutrition", + "RecipeSettings", + "RecipeNote", ] diff --git a/mealie/schema/response/__init__.py b/mealie/schema/response/__init__.py index bc0e5858876..1a498e72e4a 100644 --- a/mealie/schema/response/__init__.py +++ b/mealie/schema/response/__init__.py @@ -11,6 +11,8 @@ "QueryFilterComponent", "RelationalKeyword", "RelationalOperator", + "SearchFilter", + "ValidationResponse", "OrderByNullPosition", "OrderDirection", "PaginationBase", @@ -19,6 +21,4 @@ "ErrorResponse", "FileTokenResponse", "SuccessResponse", - "ValidationResponse", - "SearchFilter", ] diff --git a/mealie/schema/user/__init__.py b/mealie/schema/user/__init__.py index fe91630917c..0ea9b2a20db 100644 --- a/mealie/schema/user/__init__.py +++ b/mealie/schema/user/__init__.py @@ -17,7 +17,12 @@ UserIn, UserOut, UserPagination, + UserRatingCreate, + UserRatingOut, + UserRatings, + UserRatingSummary, UserSummary, + UserSummaryPagination, ) from .user_passwords import ( ForgotPassword, @@ -57,5 +62,10 @@ "UserIn", "UserOut", "UserPagination", + "UserRatingCreate", + "UserRatingOut", + "UserRatingSummary", + "UserRatings", "UserSummary", + "UserSummaryPagination", ] diff --git a/tests/utils/api_routes/__init__.py b/tests/utils/api_routes/__init__.py index 3b1eb13d475..b2e2be0f140 100644 --- a/tests/utils/api_routes/__init__.py +++ b/tests/utils/api_routes/__init__.py @@ -181,8 +181,12 @@ """`/api/users/reset-password`""" users_self = "/api/users/self" """`/api/users/self`""" +users_self_favorites = "/api/users/self/favorites" +"""`/api/users/self/favorites`""" users_self_group = "/api/users/self/group" """`/api/users/self/group`""" +users_self_ratings = "/api/users/self/ratings" +"""`/api/users/self/ratings`""" utils_download = "/api/utils/download" """`/api/utils/download`""" validators_group = "/api/validators/group" @@ -490,6 +494,16 @@ 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}" From 6888b1303b84f3421cbbf19f11ad22ff14fe02b5 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 18 Mar 2024 18:08:51 +0000 Subject: [PATCH 06/30] generate user models --- frontend/lib/api/types/user.ts | 108 +++++++++++++++------------------ 1 file changed, 48 insertions(+), 60 deletions(-) diff --git a/frontend/lib/api/types/user.ts b/frontend/lib/api/types/user.ts index 681dc5291af..c640001865c 100644 --- a/frontend/lib/api/types/user.ts +++ b/frontend/lib/api/types/user.ts @@ -5,10 +5,11 @@ /* Do not modify it by hand - just update the pydantic models and then re-run the script */ +export type WebhookType = "mealplan"; export type AuthMethod = "Mealie" | "LDAP" | "OIDC"; export interface ChangePassword { - currentPassword: string; + currentPassword?: string; newPassword: string; } export interface CreateToken { @@ -30,6 +31,11 @@ export interface CreateUserRegistration { seedData?: boolean; locale?: string; } +export interface CredentialsRequest { + username: string; + password: string; + remember_me?: boolean; +} export interface DeleteTokenResponse { tokenDelete: string; } @@ -44,7 +50,7 @@ export interface GroupInDB { id: string; slug: string; categories?: CategoryBase[]; - webhooks?: unknown[]; + webhooks?: ReadWebhook[]; users?: UserOut[]; preferences?: ReadGroupPreferences; } @@ -53,7 +59,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; @@ -61,11 +77,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[]; @@ -102,6 +116,7 @@ export interface LongLiveTokenInDB { user: PrivateUser; } export interface PrivateUser { + id: string; username?: string; fullName?: string; email: string; @@ -109,11 +124,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[]; @@ -122,6 +135,9 @@ export interface PrivateUser { loginAttemps?: number; lockedAt?: string; } +export interface OIDCRequest { + id_token: string; +} export interface PasswordResetToken { token: string; } @@ -156,9 +172,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; @@ -166,12 +190,12 @@ export interface UserBase { admin?: boolean; group?: string; advanced?: boolean; - favoriteRecipes?: string[]; canInvite?: boolean; canManage?: boolean; canOrganize?: boolean; } -export interface UserFavorites { +export interface UserIn { + id?: string; username?: string; fullName?: string; email: string; @@ -179,68 +203,32 @@ export interface UserFavorites { admin?: boolean; group?: string; advanced?: boolean; - favoriteRecipes?: RecipeSummary[]; canInvite?: boolean; canManage?: boolean; canOrganize?: boolean; + password: string; } -export interface RecipeSummary { - id?: string; - userId?: string; - groupId?: string; - name?: string; - slug?: string; - image?: unknown; - recipeYield?: string; - totalTime?: string; - prepTime?: string; - cookTime?: string; - performTime?: string; - description?: string; - recipeCategory?: RecipeCategory[]; - tags?: RecipeTag[]; - tools?: RecipeTool[]; +export interface UserRatingCreate { + recipeId: string; rating?: number; - orgURL?: string; - dateAdded?: string; - dateUpdated?: string; - createdAt?: string; - updateAt?: string; - lastMade?: string; -} -export interface RecipeCategory { - id?: string; - name: string; - slug: string; -} -export interface RecipeTag { - id?: string; - name: string; - slug: string; + isFavorite?: boolean; + userId: string; } -export interface RecipeTool { +export interface UserRatingOut { + recipeId: string; + rating?: number; + isFavorite?: boolean; + userId: string; id: string; - name: string; - slug: string; - onHand?: boolean; } -export interface UserIn { - username?: string; - fullName?: string; - email: string; - authMethod?: AuthMethod & string; - admin?: boolean; - group?: string; - advanced?: boolean; - favoriteRecipes?: string[]; - canInvite?: boolean; - canManage?: boolean; - canOrganize?: boolean; - password: string; +export interface UserRatingSummary { + recipeId: string; + rating?: number; + isFavorite?: boolean; } export interface UserSummary { id: string; - fullName?: string; + fullName: string; } export interface ValidateResetToken { token: string; From 45445305488cf14f95a30b0c149ddbe30e785f7b Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 18 Mar 2024 19:42:48 +0000 Subject: [PATCH 07/30] add single-recipe rating route --- mealie/routes/users/crud.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/mealie/routes/users/crud.py b/mealie/routes/users/crud.py index 69142084325..7c3a8b3601a 100644 --- a/mealie/routes/users/crud.py +++ b/mealie/routes/users/crud.py @@ -85,6 +85,17 @@ def get_logged_in_user(self): 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)) From 1f5a7d2364514deca4fc59f6b8e70f1212e69cbd Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Mon, 18 Mar 2024 19:43:25 +0000 Subject: [PATCH 08/30] force recalculation on recipe rating any time it's set --- mealie/db/models/recipe/recipe.py | 18 ++++++++++++++++++ mealie/db/models/users/user_to_recipe.py | 3 +-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index 17df10753ce..458362bd83a 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -7,6 +7,8 @@ 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 @@ -246,3 +248,19 @@ 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).scalar() diff --git a/mealie/db/models/users/user_to_recipe.py b/mealie/db/models/users/user_to_recipe.py index 23bdbd622cc..9361fdede40 100644 --- a/mealie/db/models/users/user_to_recipe.py +++ b/mealie/db/models/users/user_to_recipe.py @@ -23,12 +23,11 @@ def __init__(self, **_) -> None: def update_recipe_rating(session: Session, target: UserToRecipe): from mealie.db.models.recipe.recipe import RecipeModel - recipe_id = target.recipe_id recipe = session.query(RecipeModel).filter(RecipeModel.id == target.recipe_id).first() if not recipe: return - recipe.rating = session.query(func.avg(UserToRecipe.rating)).filter(UserToRecipe.recipe_id == recipe_id).scalar() + recipe.rating = -1 # this will trigger the recipe to re-calculate the rating @event.listens_for(UserToRecipe, "after_insert") From cb14ec2c99038b1ab7163bc2427b79463b3bde7f Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Tue, 19 Mar 2024 15:16:47 +0000 Subject: [PATCH 09/30] fix id datatype --- ...28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_.py | 2 +- mealie/db/models/users/user_to_recipe.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/alembic/versions/2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_.py b/alembic/versions/2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_.py index db5199df999..9d81209375b 100644 --- a/alembic/versions/2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_.py +++ b/alembic/versions/2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_.py @@ -158,7 +158,7 @@ def upgrade(): 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", sa.Integer(), 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( diff --git a/mealie/db/models/users/user_to_recipe.py b/mealie/db/models/users/user_to_recipe.py index 9361fdede40..2e56b8b65ff 100644 --- a/mealie/db/models/users/user_to_recipe.py +++ b/mealie/db/models/users/user_to_recipe.py @@ -1,5 +1,6 @@ from sqlalchemy import Boolean, Column, Float, ForeignKey, UniqueConstraint, event, func from sqlalchemy.engine.base import Connection +from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm.session import Session, object_session from .._model_base import BaseMixins, SqlAlchemyBase @@ -9,6 +10,7 @@ class UserToRecipe(SqlAlchemyBase, BaseMixins): __tablename__ = "users_to_recipes" __table_args__ = (UniqueConstraint("user_id", "recipe_id", name="user_id_recipe_id_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) From 3107d9bfb4ca04f78b077acc8a0355848a8a51d0 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Tue, 19 Mar 2024 15:57:33 +0000 Subject: [PATCH 10/30] removed unused prop --- frontend/components/Domain/Recipe/RecipeCard.vue | 2 +- .../Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue | 2 +- .../Recipe/RecipePage/RecipePageParts/RecipePageScale.vue | 1 - .../RecipePage/RecipePageParts/RecipePageTitleContent.vue | 1 - frontend/components/Domain/Recipe/RecipeRating.vue | 5 ----- 5 files changed, 2 insertions(+), 9 deletions(-) diff --git a/frontend/components/Domain/Recipe/RecipeCard.vue b/frontend/components/Domain/Recipe/RecipeCard.vue index db96996f12f..eae16a5dacc 100644 --- a/frontend/components/Domain/Recipe/RecipeCard.vue +++ b/frontend/components/Domain/Recipe/RecipeCard.vue @@ -37,7 +37,7 @@ - + diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue index b17cab0ca17..dfa85b9c583 100644 --- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue +++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageHeader.vue @@ -5,7 +5,7 @@ {{ recipe.name }} - + diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue index d42dc5d5675..ee959624168 100644 --- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue +++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageScale.vue @@ -20,7 +20,6 @@ v-if="landscape && $vuetify.breakpoint.smAndUp" :key="recipe.slug" v-model="recipe.rating" - :name="recipe.name" :slug="recipe.slug" /> diff --git a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageTitleContent.vue b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageTitleContent.vue index 2f8fcda6e21..d389bbf14da 100644 --- a/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageTitleContent.vue +++ b/frontend/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageTitleContent.vue @@ -24,7 +24,6 @@ v-if="$vuetify.breakpoint.smAndDown" :key="recipe.slug" v-model="recipe.rating" - :name="recipe.name" :slug="recipe.slug" /> diff --git a/frontend/components/Domain/Recipe/RecipeRating.vue b/frontend/components/Domain/Recipe/RecipeRating.vue index f5b312f674f..47282f10c7f 100644 --- a/frontend/components/Domain/Recipe/RecipeRating.vue +++ b/frontend/components/Domain/Recipe/RecipeRating.vue @@ -27,11 +27,6 @@ export default defineComponent({ type: Boolean, default: false, }, - // TODO Remove name prop? - name: { - type: String, - default: "", - }, slug: { type: String, default: "", From eccb3091e3c26e0cbef5f52ba607844711e290f6 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Tue, 19 Mar 2024 16:07:57 +0000 Subject: [PATCH 11/30] add back favorited by relationship with query filter --- mealie/db/models/recipe/recipe.py | 7 +++++++ mealie/db/models/users/users.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index 458362bd83a..d348739c8a1 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -55,6 +55,13 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): 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, + ) meal_entries: Mapped[list["GroupMealPlan"]] = orm.relationship( "GroupMealPlan", back_populates="recipe", cascade="all, delete-orphan" diff --git a/mealie/db/models/users/users.py b/mealie/db/models/users/users.py index 6120a88e829..64f46862ac4 100644 --- a/mealie/db/models/users/users.py +++ b/mealie/db/models/users/users.py @@ -87,6 +87,13 @@ class User(SqlAlchemyBase, BaseMixins): rated_recipes: Mapped[list["RecipeModel"]] = orm.relationship( "RecipeModel", secondary=UserToRecipe.__tablename__, back_populates="rated_by" ) + favorite_recipes: Mapped[list["RecipeModel"]] = orm.relationship( + "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={ "password", From 1f413d61b59d3e089024547c9832e573af438f6a Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Tue, 19 Mar 2024 16:08:43 +0000 Subject: [PATCH 12/30] restore favorites functionality --- .../Domain/Recipe/RecipeActionMenu.vue | 2 +- .../components/Domain/Recipe/RecipeCard.vue | 6 +++- .../Domain/Recipe/RecipeCardMobile.vue | 2 +- .../Domain/Recipe/RecipeFavoriteBadge.vue | 15 +++++--- frontend/composables/use-users/index.ts | 1 + .../composables/use-users/user-ratings.ts | 36 +++++++++++++++++++ frontend/lib/api/user/users.ts | 33 +++++++++++++++-- frontend/pages/user/_id/favorites.vue | 18 ++++++---- 8 files changed, 96 insertions(+), 17 deletions(-) create mode 100644 frontend/composables/use-users/user-ratings.ts diff --git a/frontend/components/Domain/Recipe/RecipeActionMenu.vue b/frontend/components/Domain/Recipe/RecipeActionMenu.vue index 4fd418ffbb3..694a23d3654 100644 --- a/frontend/components/Domain/Recipe/RecipeActionMenu.vue +++ b/frontend/components/Domain/Recipe/RecipeActionMenu.vue @@ -21,7 +21,7 @@
- +
diff --git a/frontend/components/Domain/Recipe/RecipeCard.vue b/frontend/components/Domain/Recipe/RecipeCard.vue index eae16a5dacc..2aeb1c0ba1a 100644 --- a/frontend/components/Domain/Recipe/RecipeCard.vue +++ b/frontend/components/Domain/Recipe/RecipeCard.vue @@ -35,7 +35,7 @@ - + @@ -97,6 +97,10 @@ export default defineComponent({ required: false, default: 0, }, + ratingColor: { + type: String, + default: "secondary", + }, image: { type: String, required: false, diff --git a/frontend/components/Domain/Recipe/RecipeCardMobile.vue b/frontend/components/Domain/Recipe/RecipeCardMobile.vue index 57c22b3f606..4bae7ae556a 100644 --- a/frontend/components/Domain/Recipe/RecipeCardMobile.vue +++ b/frontend/components/Domain/Recipe/RecipeCardMobile.vue @@ -38,7 +38,7 @@
- + 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 }; diff --git a/frontend/composables/use-users/index.ts b/frontend/composables/use-users/index.ts index f1b1a6a8571..5d3ebf01349 100644 --- a/frontend/composables/use-users/index.ts +++ b/frontend/composables/use-users/index.ts @@ -1,2 +1,3 @@ export { useUserForm } from "./user-form"; export { useUserRegistrationForm } from "./user-registration-form"; +export { useUserSelfRatings } from "./user-ratings"; diff --git a/frontend/composables/use-users/user-ratings.ts b/frontend/composables/use-users/user-ratings.ts new file mode 100644 index 00000000000..c595d44540b --- /dev/null +++ b/frontend/composables/use-users/user-ratings.ts @@ -0,0 +1,36 @@ +import { ref, useContext } from "@nuxtjs/composition-api"; +import { useUserApi } from "~/composables/api"; +import { UserRatingSummary } from "~/lib/api/types/user"; + +const userRatings = ref([]); +const loading = ref(false); + +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; + } + + 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; + } + + refreshUserRatings(); + return { + userRatings, + refreshUserRatings, + setRating, + } +} diff --git a/frontend/lib/api/user/users.ts b/frontend/lib/api/user/users.ts index 2a4810e94c8..9e0cfa1763e 100644 --- a/frontend/lib/api/user/users.ts +++ b/frontend/lib/api/user/users.ts @@ -9,17 +9,27 @@ import { LongLiveTokenOut, ResetPassword, UserBase, - UserFavorites, UserIn, UserOut, + UserRatingOut, + UserRatingSummary, UserSummary, } from "~/lib/api/types/user"; +export interface UserRatingsSummaries { + ratings: UserRatingSummary[]; +} + +export interface UserRatingsOut { + ratings: UserRatingOut[]; +} + const prefix = "/api"; const routes = { groupUsers: `${prefix}/users/group-users`, usersSelf: `${prefix}/users/self`, + ratingsSelf: `${prefix}/users/self/ratings`, groupsSelf: `${prefix}/users/self/group`, passwordReset: `${prefix}/users/reset-password`, passwordChange: `${prefix}/users/password`, @@ -30,6 +40,9 @@ 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`, + 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 +69,23 @@ export class UserApi extends BaseCRUDAPI { } async getFavorites(id: string) { - return await this.requests.get(routes.usersIdFavorites(id)); + return await this.requests.get(routes.usersIdFavorites(id)); + } + + async getSelfFavorites() { + return await this.requests.get(routes.ratingsSelf); + } + + async getRatings(id: string) { + return await this.requests.get(routes.usersIdRatings(id)); + } + + async setRating(id: string, slug: string, rating: number | null, isFavorite: boolean | null) { + return await this.requests.post(routes.usersIdFavoritesSlug(id, slug), { rating, isFavorite }); + } + + async getSelfRatings() { + return await this.requests.get(routes.ratingsSelf); } async changePassword(changePassword: ChangePassword) { diff --git a/frontend/pages/user/_id/favorites.vue b/frontend/pages/user/_id/favorites.vue index 603428d428e..1942fccdab2 100644 --- a/frontend/pages/user/_id/favorites.vue +++ b/frontend/pages/user/_id/favorites.vue @@ -1,7 +1,11 @@ @@ -21,14 +25,14 @@ 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}"` }); + console.log(data?.items); + return data?.items || null; }, useAsyncKey()); return { - user, + recipes, isOwnGroup, }; }, From e103d2155d516930eca078be451f1d9debbcd6fc Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Tue, 19 Mar 2024 16:34:35 +0000 Subject: [PATCH 13/30] removed debug log --- frontend/pages/user/_id/favorites.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/pages/user/_id/favorites.vue b/frontend/pages/user/_id/favorites.vue index 1942fccdab2..e7114a2b9c2 100644 --- a/frontend/pages/user/_id/favorites.vue +++ b/frontend/pages/user/_id/favorites.vue @@ -27,7 +27,6 @@ export default defineComponent({ const userId = route.value.params.id; const recipes = useAsync(async () => { const { data } = await api.recipes.getAll(1, -1, { queryFilter: `favoritedBy.id = "${userId}"` }); - console.log(data?.items); return data?.items || null; }, useAsyncKey()); From a8070e55a4a931d5e7530d377e0dddb45e382a31 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Tue, 19 Mar 2024 16:49:04 +0000 Subject: [PATCH 14/30] fix several API issues --- .../components/Domain/Recipe/RecipeRating.vue | 10 +++------- frontend/lib/api/user/users.ts | 3 ++- mealie/db/models/recipe/recipe.py | 6 +++++- mealie/db/models/users/user_to_recipe.py | 10 +--------- mealie/routes/users/ratings.py | 16 ++++++++-------- mealie/schema/user/user.py | 5 +++++ 6 files changed, 24 insertions(+), 26 deletions(-) diff --git a/frontend/components/Domain/Recipe/RecipeRating.vue b/frontend/components/Domain/Recipe/RecipeRating.vue index 47282f10c7f..8e58e49aab0 100644 --- a/frontend/components/Domain/Recipe/RecipeRating.vue +++ b/frontend/components/Domain/Recipe/RecipeRating.vue @@ -18,7 +18,7 @@ From f89dd8b1a9ead9846b0ed86761d278e372ab82d0 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Tue, 19 Mar 2024 19:33:49 +0000 Subject: [PATCH 21/30] fix recipe creation rating --- mealie/services/recipe/recipe_service.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 45a699eded1..7fde8a2b170 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -20,7 +20,7 @@ from mealie.schema.recipe.recipe_step import RecipeStep from mealie.schema.recipe.recipe_timeline_events import RecipeTimelineEventCreate, TimelineEventType from mealie.schema.recipe.request_helpers import RecipeDuplicate -from mealie.schema.user.user import GroupInDB, PrivateUser +from mealie.schema.user.user import GroupInDB, PrivateUser, UserRatingCreate from mealie.services._base_service import BaseService from mealie.services.recipe.recipe_data_service import RecipeDataService @@ -145,8 +145,20 @@ def create_one(self, create_data: Recipe | CreateRecipe) -> Recipe: else: data.settings = RecipeSettings() + rating_input = data.rating new_recipe = self.repos.recipes.create(data) + # convert rating into user rating + if rating_input: + self.repos.user_ratings.create( + UserRatingCreate( + user_id=self.user.id, + recipe_id=new_recipe.id, + rating=rating_input, + is_favorite=False, + ) + ) + # create first timeline entry timeline_event_data = RecipeTimelineEventCreate( user_id=new_recipe.user_id, From 2dc2685b5dce31dd90c72e06f134370f3c0745c6 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Tue, 19 Mar 2024 20:26:13 +0000 Subject: [PATCH 22/30] added migration tests --- .../backup_v2_tests/test_backup_v2.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py b/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py index e20cfaf2694..f072d58793d 100644 --- a/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py +++ b/tests/unit_tests/services_tests/backup_v2_tests/test_backup_v2.py @@ -1,4 +1,5 @@ import filecmp +import statistics from pathlib import Path from typing import Any, cast @@ -8,11 +9,14 @@ import tests.data as test_data from mealie.core.config import get_app_settings from mealie.db.db_setup import session_context +from mealie.db.models._model_utils import GUID from mealie.db.models.group import Group from mealie.db.models.group.shopping_list import ShoppingList from mealie.db.models.labels import MultiPurposeLabel from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel from mealie.db.models.recipe.recipe import RecipeModel +from mealie.db.models.users.user_to_recipe import UserToRecipe +from mealie.db.models.users.users import User from mealie.services.backups_v2.alchemy_exporter import AlchemyExporter from mealie.services.backups_v2.backup_file import BackupFile from mealie.services.backups_v2.backup_v2 import BackupV2 @@ -155,5 +159,18 @@ def test_database_restore_data(backup_path: Path): assert unit.name_normalized if unit.abbreviation: assert unit.abbreviation_normalized + + # 2024-03-18-02.28.15_d7c6efd2de42_migrate_favorites_and_ratings_to_user_ratings + users_by_group_id: dict[GUID, list[User]] = {} + for recipe in recipes: + users = users_by_group_id.get(recipe.group_id) + if users is None: + users = session.query(User).filter(User.group_id == recipe.group_id).all() + users_by_group_id[recipe.group_id] = users + + user_to_recipes = session.query(UserToRecipe).filter(UserToRecipe.recipe_id == recipe.id).all() + user_ratings = [x.rating for x in user_to_recipes if x.rating] + assert recipe.rating == (statistics.mean(user_ratings) if user_ratings else None) + finally: backup_v2.restore(original_data_backup) From 66ff6004e6473bc208c032e4168110f8f5dd3817 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Tue, 19 Mar 2024 21:09:21 +0000 Subject: [PATCH 23/30] show half ratings when not hovering --- frontend/components/Domain/Recipe/RecipeRating.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/components/Domain/Recipe/RecipeRating.vue b/frontend/components/Domain/Recipe/RecipeRating.vue index 5a6af864370..45abdb9534f 100644 --- a/frontend/components/Domain/Recipe/RecipeRating.vue +++ b/frontend/components/Domain/Recipe/RecipeRating.vue @@ -3,6 +3,7 @@ Date: Tue, 19 Mar 2024 21:20:32 +0000 Subject: [PATCH 24/30] clean up some jank imports --- frontend/components/Domain/Group/GroupMealPlanRuleForm.vue | 2 +- frontend/components/Domain/Recipe/RecipeChips.vue | 2 +- frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue | 2 +- frontend/composables/recipes/use-recipe-tools.ts | 2 +- frontend/composables/store/use-category-store.ts | 2 +- frontend/pages/group/data/categories.vue | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/components/Domain/Group/GroupMealPlanRuleForm.vue b/frontend/components/Domain/Group/GroupMealPlanRuleForm.vue index b475f26a9d7..cf71112a3ee 100644 --- a/frontend/components/Domain/Group/GroupMealPlanRuleForm.vue +++ b/frontend/components/Domain/Group/GroupMealPlanRuleForm.vue @@ -19,7 +19,7 @@