diff --git a/migrations/versions/fc7cb93684e0_likes.py b/migrations/versions/fc7cb93684e0_likes.py new file mode 100644 index 0000000..224829e --- /dev/null +++ b/migrations/versions/fc7cb93684e0_likes.py @@ -0,0 +1,42 @@ +"""likes + +Revision ID: fc7cb93684e0 +Revises: 1c001709fc55 +Create Date: 2025-07-27 05:50:58.474948 + +""" + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = 'fc7cb93684e0' +down_revision = '1c001709fc55' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + '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('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( + ['comment_uuid'], + ['comment.uuid'], + ), + sa.PrimaryKeyConstraint('uuid'), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('comment_reaction') + # ### end Alembic commands ### diff --git a/rating_api/models/db.py b/rating_api/models/db.py index 99d46d6..18438c5 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) + reactions: Mapped[list[CommentReaction]] = relationship( + "CommentReaction", back_populates="comment", cascade="all, delete-orphan" + ) is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) @hybrid_property @@ -151,11 +169,45 @@ 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.reactions if like.reaction == 'like') + + @hybrid_property + def dislike_count(self) -> int: + """Python access to dislike count""" + return sum(1 for like in self.reactions if like.reaction == 'dislike') + 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 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) + 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="reactions") diff --git a/rating_api/routes/comment.py b/rating_api/routes/comment.py index 529398c..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, 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, @@ -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,49 @@ async def delete_comment( return StatusResponseModel( status="Success", message="Comment has been deleted", ru="Комментарий удален из RatingAPI" ) + + +@comment.put("/{uuid}/{reaction}", response_model=CommentGet) +async def like_comment( + uuid: UUID, + reaction: Reaction, + user=Depends(UnionAuth()), +) -> CommentGet: + """ + 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 (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 with reactions in CommentGet format. + + Raises: + ObjectNotFound: If the comment with given UUID doesn't exist. + """ + comment = Comment.get(session=db.session, id=uuid) + if not comment: + raise ObjectNotFound(Comment, uuid) + + existing_reaction = ( + CommentReaction.query(session=db.session) + .filter( + CommentReaction.user_id == user.get("id"), + CommentReaction.comment_uuid == comment.uuid, + ) + .first() + ) + + 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: + CommentReaction.delete(session=db.session, id=existing_reaction.uuid) + return CommentGet.model_validate(comment) diff --git a/rating_api/schemas/models.py b/rating_api/schemas/models.py index af01659..da53351 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 + dislike_count: int 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): diff --git a/tests/test_routes/test_comment.py b/tests/test_routes/test_comment.py index d894cac..6952e66 100644 --- a/tests/test_routes/test_comment.py +++ b/tests/test_routes/test_comment.py @@ -358,3 +358,25 @@ 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): + # 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 + + # 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 + + # 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 + assert comment.dislike_count == 0