From 100750194caf6c573f66c6d00de4e56a292ad4c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=97=D0=B0=D1=85=D0=B0=D1=80=D0=BE=D0=B2=20=D0=98=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=9C=D0=B8=D1=85=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=87?= Date: Sat, 26 Jul 2025 13:11:33 +0000 Subject: [PATCH 1/4] init --- migrations/versions/3c98a82bae8d_likes.py | 42 ++++++++++++++++++ rating_api/models/db.py | 53 +++++++++++++++++++++-- rating_api/routes/comment.py | 35 ++++++++++++++- rating_api/schemas/models.py | 6 +++ 4 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 migrations/versions/3c98a82bae8d_likes.py diff --git a/migrations/versions/3c98a82bae8d_likes.py b/migrations/versions/3c98a82bae8d_likes.py new file mode 100644 index 0000000..55bf05c --- /dev/null +++ b/migrations/versions/3c98a82bae8d_likes.py @@ -0,0 +1,42 @@ +"""likes + +Revision ID: 3c98a82bae8d +Revises: 1c001709fc55 +Create Date: 2025-07-26 13:08:56.891422 + +""" + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = '3c98a82bae8d' +down_revision = '1c001709fc55' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + 'comment_like', + sa.Column('uuid', sa.UUID(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('comment_uuid', sa.UUID(), nullable=False), + sa.Column('like', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('edited_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ['comment_uuid'], + ['comment.uuid'], + ), + sa.PrimaryKeyConstraint('uuid'), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('comment_like') + # ### end Alembic commands ### diff --git a/rating_api/models/db.py b/rating_api/models/db.py index 99d46d6..55965ce 100644 --- a/rating_api/models/db.py +++ b/rating_api/models/db.py @@ -6,9 +6,24 @@ from enum import Enum from fastapi_sqlalchemy import db -from sqlalchemy import UUID, Boolean, DateTime +from sqlalchemy import ( + UUID, + Boolean, + DateTime, +) from sqlalchemy import Enum as DbEnum -from sqlalchemy import ForeignKey, Integer, String, UnaryExpression, and_, desc, func, nulls_last, or_, true +from sqlalchemy import ( + ForeignKey, + Integer, + String, + UnaryExpression, + and_, + desc, + func, + nulls_last, + or_, + true, +) from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm.attributes import InstrumentedAttribute @@ -117,6 +132,9 @@ class Comment(BaseDbModel): primaryjoin="and_(Comment.lecturer_id == Lecturer.id, not_(Lecturer.is_deleted))", ) review_status: Mapped[ReviewStatus] = mapped_column(DbEnum(ReviewStatus, native_enum=False), nullable=False) + likes: Mapped[list[CommentLike]] = relationship( + "CommentLike", back_populates="comment", cascade="all, delete-orphan" + ) is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) @hybrid_property @@ -151,11 +169,38 @@ def search_by_subject(self, query: str) -> bool: return true() return func.lower(Comment.subject).contains(query.lower()) + @hybrid_property + def like_count(self) -> int: + """Python access to like count""" + return sum(1 for like in self.likes if like.like == 1) + + @hybrid_property + def dislike_count(self) -> int: + """Python access to dislike count""" + return sum(1 for like in self.likes if like.like == -1) + class LecturerUserComment(BaseDbModel): id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[int] = mapped_column(Integer, nullable=False) lecturer_id: Mapped[int] = mapped_column(Integer, ForeignKey("lecturer.id")) - create_ts: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow, nullable=False) - update_ts: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow, nullable=False) + create_ts: Mapped[datetime.datetime] = mapped_column( + DateTime, default=datetime.datetime.now(datetime.timezone.utc), nullable=False + ) + update_ts: Mapped[datetime.datetime] = mapped_column( + DateTime, default=datetime.datetime.now(datetime.timezone.utc), nullable=False + ) is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + +class CommentLike(BaseDbModel): + uuid: Mapped[uuid.UUID] = mapped_column(UUID, primary_key=True, default=uuid.uuid4) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + comment_uuid: Mapped[UUID] = mapped_column(UUID, ForeignKey("comment.uuid"), nullable=False) + like: Mapped[int] = mapped_column(Integer, default=0) # 1 for like, -1 for dislike + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime, default=datetime.datetime.now(datetime.timezone.utc) + ) + edited_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.now(datetime.timezone.utc)) + user_id: Mapped[int] = mapped_column(Integer, nullable=False) + comment = relationship("Comment", back_populates="likes") diff --git a/rating_api/routes/comment.py b/rating_api/routes/comment.py index 529398c..af37895 100644 --- a/rating_api/routes/comment.py +++ b/rating_api/routes/comment.py @@ -17,7 +17,7 @@ TooManyCommentsToLecturer, UpdateError, ) -from rating_api.models import Comment, Lecturer, LecturerUserComment, ReviewStatus +from rating_api.models import Comment, CommentLike, Lecturer, LecturerUserComment, ReviewStatus from rating_api.schemas.base import StatusResponseModel from rating_api.schemas.models import ( CommentGet, @@ -214,7 +214,6 @@ async def get_comments( else Comment.order_by_create_ts(order_by, asc_order) ) ) - comments = comments_query.limit(limit).offset(offset).all() if not comments: raise ObjectNotFound(Comment, 'all') @@ -317,3 +316,35 @@ async def delete_comment( return StatusResponseModel( status="Success", message="Comment has been deleted", ru="Комментарий удален из RatingAPI" ) + + +@comment.post("/{uuid}/like", response_model=CommentGet) +async def like_comment( + uuid: UUID, + like: Literal["1", "-1"] = Query(description="1 for like, -1 for dislike"), + user=Depends(UnionAuth()), +) -> CommentGet: + """ + Likes or dislikes a comment by UUID + """ + like = int(like) + comment = Comment.get(session=db.session, id=uuid) + if not comment: + raise ObjectNotFound(Comment, uuid) + + existing_like = ( + CommentLike.query(session=db.session) + .filter( + CommentLike.user_id == user.get("id"), + CommentLike.comment_uuid == comment.uuid, + ) + .first() + ) + + if existing_like and existing_like.like != like: + new_like = CommentLike.update(session=db.session, id=existing_like.uuid, like=like) + elif not existing_like: + CommentLike.create(session=db.session, user_id=user.get("id"), comment_uuid=comment.uuid, like=like) + else: + CommentLike.delete(session=db.session, id=existing_like.uuid) + return CommentGet.model_validate(comment) diff --git a/rating_api/schemas/models.py b/rating_api/schemas/models.py index af01659..06e3d69 100644 --- a/rating_api/schemas/models.py +++ b/rating_api/schemas/models.py @@ -20,6 +20,8 @@ class CommentGet(Base): mark_clarity: int mark_general: float lecturer_id: int + like_count: int + dislike_count: int class CommentGetWithStatus(Base): @@ -35,6 +37,8 @@ class CommentGetWithStatus(Base): mark_general: float lecturer_id: int review_status: ReviewStatus + like_count: int = 0 + dislike_count: int = 0 class CommentGetWithAllInfo(Base): @@ -51,6 +55,8 @@ class CommentGetWithAllInfo(Base): lecturer_id: int review_status: ReviewStatus approved_by: int | None = None + like_count: int + dislike_count: int class CommentPost(Base): From ae236d2cd249f2cde8dfda5d0600e3223699ae12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=97=D0=B0=D1=85=D0=B0=D1=80=D0=BE=D0=B2=20=D0=98=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=9C=D0=B8=D1=85=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=87?= Date: Sat, 26 Jul 2025 13:31:22 +0000 Subject: [PATCH 2/4] docs + test --- rating_api/routes/comment.py | 13 ++++++++++++- tests/test_routes/test_comment.py | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/rating_api/routes/comment.py b/rating_api/routes/comment.py index af37895..ce8fe91 100644 --- a/rating_api/routes/comment.py +++ b/rating_api/routes/comment.py @@ -325,7 +325,18 @@ async def like_comment( user=Depends(UnionAuth()), ) -> CommentGet: """ - Likes or dislikes a comment by UUID + Likes or dislikes a comment by UUID. + + Args: + uuid: The UUID of the comment to like/dislike. + like: The type of reaction ("1" for like, "-1" for dislike). + user: The authenticated user obtained from UnionAuth dependency. + + Returns: + CommentGet: The updated comment data. + + Raises: + ObjectNotFound: If the comment with given UUID is not found. """ like = int(like) comment = Comment.get(session=db.session, id=uuid) diff --git a/tests/test_routes/test_comment.py b/tests/test_routes/test_comment.py index d894cac..1c1f8e5 100644 --- a/tests/test_routes/test_comment.py +++ b/tests/test_routes/test_comment.py @@ -358,3 +358,20 @@ def test_update_comment(client, dbsession, nonanonymous_comment, body, response_ # assert comment.is_deleted # response = client.get(f'{url}/{comment.uuid}') # assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_post_like(client, dbsession, comment): + response = client.post(f'{url}/{comment.uuid}/like', params={'like': 1}) + assert response.status_code == status.HTTP_200_OK + dbsession.refresh(comment) + assert comment.like_count == 1 + response = client.post(f'{url}/{comment.uuid}/like', params={'like': -1}) + assert response.status_code == status.HTTP_200_OK + dbsession.refresh(comment) + assert comment.like_count == 0 + assert comment.dislike_count == 1 + response = client.post(f'{url}/{comment.uuid}/like', params={'like': -1}) + assert response.status_code == status.HTTP_200_OK + dbsession.refresh(comment) + assert comment.like_count == 0 + assert comment.dislike_count == 0 From 6fbcc86664bbebba9680c439284870bad417437d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=97=D0=B0=D1=85=D0=B0=D1=80=D0=BE=D0=B2=20=D0=98=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=9C=D0=B8=D1=85=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=87?= Date: Sun, 27 Jul 2025 06:03:54 +0000 Subject: [PATCH 3/4] int reactions -> enum. route arg instead of query --- ...82bae8d_likes.py => fc7cb93684e0_likes.py} | 12 +++--- rating_api/models/db.py | 21 ++++++---- rating_api/routes/comment.py | 41 ++++++++++--------- tests/test_routes/test_comment.py | 11 +++-- 4 files changed, 50 insertions(+), 35 deletions(-) rename migrations/versions/{3c98a82bae8d_likes.py => fc7cb93684e0_likes.py} (77%) diff --git a/migrations/versions/3c98a82bae8d_likes.py b/migrations/versions/fc7cb93684e0_likes.py similarity index 77% rename from migrations/versions/3c98a82bae8d_likes.py rename to migrations/versions/fc7cb93684e0_likes.py index 55bf05c..224829e 100644 --- a/migrations/versions/3c98a82bae8d_likes.py +++ b/migrations/versions/fc7cb93684e0_likes.py @@ -1,8 +1,8 @@ """likes -Revision ID: 3c98a82bae8d +Revision ID: fc7cb93684e0 Revises: 1c001709fc55 -Create Date: 2025-07-26 13:08:56.891422 +Create Date: 2025-07-27 05:50:58.474948 """ @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. -revision = '3c98a82bae8d' +revision = 'fc7cb93684e0' down_revision = '1c001709fc55' branch_labels = None depends_on = None @@ -20,11 +20,11 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table( - 'comment_like', + 'comment_reaction', sa.Column('uuid', sa.UUID(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('comment_uuid', sa.UUID(), nullable=False), - sa.Column('like', sa.Integer(), nullable=False), + sa.Column('reaction', sa.Enum('LIKE', 'DISLIKE', name='reaction', native_enum=False), nullable=False), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('edited_at', sa.DateTime(), nullable=False), sa.ForeignKeyConstraint( @@ -38,5 +38,5 @@ def upgrade(): def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('comment_like') + op.drop_table('comment_reaction') # ### end Alembic commands ### diff --git a/rating_api/models/db.py b/rating_api/models/db.py index 55965ce..18438c5 100644 --- a/rating_api/models/db.py +++ b/rating_api/models/db.py @@ -132,8 +132,8 @@ class Comment(BaseDbModel): primaryjoin="and_(Comment.lecturer_id == Lecturer.id, not_(Lecturer.is_deleted))", ) review_status: Mapped[ReviewStatus] = mapped_column(DbEnum(ReviewStatus, native_enum=False), nullable=False) - likes: Mapped[list[CommentLike]] = relationship( - "CommentLike", back_populates="comment", cascade="all, delete-orphan" + reactions: Mapped[list[CommentReaction]] = relationship( + "CommentReaction", back_populates="comment", cascade="all, delete-orphan" ) is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) @@ -172,12 +172,12 @@ def search_by_subject(self, query: str) -> bool: @hybrid_property def like_count(self) -> int: """Python access to like count""" - return sum(1 for like in self.likes if like.like == 1) + return sum(1 for like in self.reactions if like.reaction == 'like') @hybrid_property def dislike_count(self) -> int: """Python access to dislike count""" - return sum(1 for like in self.likes if like.like == -1) + return sum(1 for like in self.reactions if like.reaction == 'dislike') class LecturerUserComment(BaseDbModel): @@ -193,14 +193,21 @@ class LecturerUserComment(BaseDbModel): is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) -class CommentLike(BaseDbModel): +class Reaction(str, Enum): + LIKE: str = "like" + DISLIKE: str = "dislike" + + +class CommentReaction(BaseDbModel): uuid: Mapped[uuid.UUID] = mapped_column(UUID, primary_key=True, default=uuid.uuid4) user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) comment_uuid: Mapped[UUID] = mapped_column(UUID, ForeignKey("comment.uuid"), nullable=False) - like: Mapped[int] = mapped_column(Integer, default=0) # 1 for like, -1 for dislike + reaction: Mapped[Reaction] = mapped_column( + DbEnum(Reaction, native_enum=False), nullable=False + ) # 1 for like, -1 for dislike created_at: Mapped[datetime.datetime] = mapped_column( DateTime, default=datetime.datetime.now(datetime.timezone.utc) ) edited_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.now(datetime.timezone.utc)) user_id: Mapped[int] = mapped_column(Integer, nullable=False) - comment = relationship("Comment", back_populates="likes") + comment = relationship("Comment", back_populates="reactions") diff --git a/rating_api/routes/comment.py b/rating_api/routes/comment.py index ce8fe91..3b50663 100644 --- a/rating_api/routes/comment.py +++ b/rating_api/routes/comment.py @@ -17,7 +17,7 @@ TooManyCommentsToLecturer, UpdateError, ) -from rating_api.models import Comment, CommentLike, Lecturer, LecturerUserComment, ReviewStatus +from rating_api.models import Comment, CommentReaction, Lecturer, LecturerUserComment, Reaction, ReviewStatus from rating_api.schemas.base import StatusResponseModel from rating_api.schemas.models import ( CommentGet, @@ -318,44 +318,47 @@ async def delete_comment( ) -@comment.post("/{uuid}/like", response_model=CommentGet) +@comment.put("/{uuid}/{reaction}", response_model=CommentGet) async def like_comment( uuid: UUID, - like: Literal["1", "-1"] = Query(description="1 for like, -1 for dislike"), + reaction: Reaction, user=Depends(UnionAuth()), ) -> CommentGet: """ - Likes or dislikes a comment by UUID. + Handles like/dislike reactions for a comment. + + This endpoint allows authenticated users to react to a comment (like/dislike) or change their existing reaction. + If the user has no existing reaction, a new one is created. If the user changes their reaction, it gets updated. + If the user clicks the same reaction again, the reaction is removed. Args: - uuid: The UUID of the comment to like/dislike. - like: The type of reaction ("1" for like, "-1" for dislike). - user: The authenticated user obtained from UnionAuth dependency. + uuid (UUID): The UUID of the comment to react to. + reaction (Reaction): The reaction type (like/dislike). + user (dict): Authenticated user data from UnionAuth dependency. Returns: - CommentGet: The updated comment data. + CommentGet: The updated comment with reactions in CommentGet format. Raises: - ObjectNotFound: If the comment with given UUID is not found. + ObjectNotFound: If the comment with given UUID doesn't exist. """ - like = int(like) comment = Comment.get(session=db.session, id=uuid) if not comment: raise ObjectNotFound(Comment, uuid) - existing_like = ( - CommentLike.query(session=db.session) + existing_reaction = ( + CommentReaction.query(session=db.session) .filter( - CommentLike.user_id == user.get("id"), - CommentLike.comment_uuid == comment.uuid, + CommentReaction.user_id == user.get("id"), + CommentReaction.comment_uuid == comment.uuid, ) .first() ) - if existing_like and existing_like.like != like: - new_like = CommentLike.update(session=db.session, id=existing_like.uuid, like=like) - elif not existing_like: - CommentLike.create(session=db.session, user_id=user.get("id"), comment_uuid=comment.uuid, like=like) + if existing_reaction and existing_reaction.reaction != reaction: + new_reaction = CommentReaction.update(session=db.session, id=existing_reaction.uuid, reaction=reaction) + elif not existing_reaction: + CommentReaction.create(session=db.session, user_id=user.get("id"), comment_uuid=comment.uuid, reaction=reaction) else: - CommentLike.delete(session=db.session, id=existing_like.uuid) + CommentReaction.delete(session=db.session, id=existing_reaction.uuid) return CommentGet.model_validate(comment) diff --git a/tests/test_routes/test_comment.py b/tests/test_routes/test_comment.py index 1c1f8e5..6952e66 100644 --- a/tests/test_routes/test_comment.py +++ b/tests/test_routes/test_comment.py @@ -361,16 +361,21 @@ def test_update_comment(client, dbsession, nonanonymous_comment, body, response_ def test_post_like(client, dbsession, comment): - response = client.post(f'{url}/{comment.uuid}/like', params={'like': 1}) + # Like + response = client.put(f'{url}/{comment.uuid}/like') assert response.status_code == status.HTTP_200_OK dbsession.refresh(comment) assert comment.like_count == 1 - response = client.post(f'{url}/{comment.uuid}/like', params={'like': -1}) + + # Dislike + response = client.put(f'{url}/{comment.uuid}/dislike') assert response.status_code == status.HTTP_200_OK dbsession.refresh(comment) assert comment.like_count == 0 assert comment.dislike_count == 1 - response = client.post(f'{url}/{comment.uuid}/like', params={'like': -1}) + + # click dislike one more time + response = client.put(f'{url}/{comment.uuid}/dislike') assert response.status_code == status.HTTP_200_OK dbsession.refresh(comment) assert comment.like_count == 0 From 5717da8cca7f95d9b1f69e47ed694aa991e1dc95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=97=D0=B0=D1=85=D0=B0=D1=80=D0=BE=D0=B2=20=D0=98=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=9C=D0=B8=D1=85=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=87?= Date: Sun, 27 Jul 2025 06:11:47 +0000 Subject: [PATCH 4/4] fix --- rating_api/schemas/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rating_api/schemas/models.py b/rating_api/schemas/models.py index 06e3d69..da53351 100644 --- a/rating_api/schemas/models.py +++ b/rating_api/schemas/models.py @@ -37,8 +37,8 @@ class CommentGetWithStatus(Base): mark_general: float lecturer_id: int review_status: ReviewStatus - like_count: int = 0 - dislike_count: int = 0 + like_count: int + dislike_count: int class CommentGetWithAllInfo(Base):