Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions migrations/versions/fc7cb93684e0_likes.py
Original file line number Diff line number Diff line change
@@ -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 ###
60 changes: 56 additions & 4 deletions rating_api/models/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Comment on lines +187 to +192
Copy link
Member Author

@Zimovchik Zimovchik Jul 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

linting+better utcnow call

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")
49 changes: 47 additions & 2 deletions rating_api/routes/comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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)
6 changes: 6 additions & 0 deletions rating_api/schemas/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand Down
22 changes: 22 additions & 0 deletions tests/test_routes/test_comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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