diff --git a/migrations/versions/7e40ff9486ce_like_db.py b/migrations/versions/7e40ff9486ce_like_db.py new file mode 100644 index 0000000..7a97213 --- /dev/null +++ b/migrations/versions/7e40ff9486ce_like_db.py @@ -0,0 +1,38 @@ +"""like_db + +Revision ID: 7e40ff9486ce +Revises: 5cf69f1026d9 +Create Date: 2025-02-14 08:24:17.387577 + +""" + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = '7e40ff9486ce' +down_revision = '5cf69f1026d9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + 'comment_like', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('comment_uuid', sa.UUID(), nullable=False), + sa.Column('create_ts', sa.DateTime(), nullable=False), + sa.Column('is_deleted', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['comment_uuid'], ['comment.uuid']), + sa.PrimaryKeyConstraint('id'), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('likes') + # ### end Alembic commands ### diff --git a/rating_api/models/__init__.py b/rating_api/models/__init__.py index 58d469c..afe971b 100644 --- a/rating_api/models/__init__.py +++ b/rating_api/models/__init__.py @@ -2,4 +2,4 @@ from .db import * -__all__ = ["Base", "BaseDbModel", "Lecturer", "LecturerUserComment", "Comment"] +__all__ = ["Base", "BaseDbModel", "Lecturer", "LecturerUserComment", "Comment", "Like"] diff --git a/rating_api/models/db.py b/rating_api/models/db.py index 61b6c63..5a17e08 100644 --- a/rating_api/models/db.py +++ b/rating_api/models/db.py @@ -8,7 +8,7 @@ from fastapi_sqlalchemy import db from sqlalchemy import UUID, Boolean, DateTime from sqlalchemy import Enum as DbEnum -from sqlalchemy import ForeignKey, Integer, String, UnaryExpression, and_, func, nulls_last, or_, true +from sqlalchemy import ForeignKey, Integer, String, UnaryExpression, and_, func, nulls_last, or_, select, true from sqlalchemy.ext.hybrid import hybrid_method, hybrid_property from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm.attributes import InstrumentedAttribute @@ -130,3 +130,11 @@ class LecturerUserComment(BaseDbModel): 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) is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + +class CommentLike(BaseDbModel): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, nullable=False) + comment_uuid: Mapped[uuid.UUID] = mapped_column(UUID, nullable=False) + create_ts: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow, nullable=False) + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) diff --git a/rating_api/routes/base.py b/rating_api/routes/base.py index 3d725d7..f0c9c7d 100644 --- a/rating_api/routes/base.py +++ b/rating_api/routes/base.py @@ -5,6 +5,7 @@ from rating_api import __version__ from rating_api.routes.comment import comment from rating_api.routes.lecturer import lecturer +from rating_api.routes.like import like from rating_api.settings import Settings, get_settings from rating_api.utils.logging_utils import get_request_body, log_request @@ -36,6 +37,7 @@ app.include_router(lecturer) app.include_router(comment) +app.include_router(like) @app.middleware("http") diff --git a/rating_api/routes/comment.py b/rating_api/routes/comment.py index c973ddd..f382a58 100644 --- a/rating_api/routes/comment.py +++ b/rating_api/routes/comment.py @@ -7,6 +7,9 @@ from auth_lib.fastapi import UnionAuth from fastapi import APIRouter, Depends, Query from fastapi_sqlalchemy import db +from sqlalchemy import func, select +from sqlalchemy.orm import aliased + from rating_api.exceptions import ( CommentTooLong, @@ -17,7 +20,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, @@ -160,8 +163,10 @@ async def get_comment(uuid: UUID) -> CommentGet: Возвращает комментарий по его UUID в базе данных RatingAPI """ comment: Comment = Comment.query(session=db.session).filter(Comment.uuid == uuid).one_or_none() + like_count = CommentLike.query(session=db.session).filter(CommentLike.comment_uuid == uuid).count() if comment is None: raise ObjectNotFound(Comment, uuid) + comment.like_count = like_count return CommentGet.model_validate(comment) @@ -180,33 +185,33 @@ async def get_comments( `limit` - максимальное количество возвращаемых комментариев - `offset` - смещение, определяющее, с какого по порядку комментария начинать выборку. - Если без смещения возвращается комментарий с условным номером N, - то при значении offset = X будет возвращаться комментарий с номером N + X + `offset` - смещение, определяющее, с какого по порядку комментария начинать выборку. - `order_by` - возможное значение `'create_ts'` - возвращается список комментариев отсортированных по времени создания + `order_by` - возможное значение `'create_ts'` - возвращается список комментариев, отсортированных по времени создания. - `lecturer_id` - вернет все комментарии для преподавателя с конкретным id, по дефолту возвращает вообще все аппрувнутые комментарии. + `lecturer_id` - вернет все комментарии для преподавателя с конкретным id. - `user_id` - вернет все комментарии пользователя с конкретным id + `user_id` - вернет все комментарии пользователя с конкретным id. `unreviewed` - вернет все непроверенные комментарии, если True. По дефолту False. """ - comments = Comment.query(session=db.session).all() - if not comments: - raise ObjectNotFound(Comment, 'all') - if "rating.comment.review" in [scope['name'] for scope in user.get('session_scopes')] or user.get('id') == user_id: - result = CommentGetAllWithStatus(limit=limit, offset=offset, total=len(comments)) - comment_validator = CommentGetWithStatus - else: - result = CommentGetAll(limit=limit, offset=offset, total=len(comments)) - comment_validator = CommentGet - result.comments = comments - if user_id is not None: - result.comments = [comment for comment in result.comments if comment.user_id == user_id] + # Subquery чтобы посчитать лайки + like_counts = ( + select(CommentLike.comment_uuid, func.count(CommentLike.id).label('like_count')) + .where(CommentLike.is_deleted == False) + .group_by(CommentLike.comment_uuid) + .alias("like_counts") + ) + # получаем комменты с лайками к ним + comments_query = select(Comment, func.coalesce(like_counts.c.like_count, 0).label('like_count')).outerjoin( + like_counts, Comment.uuid == like_counts.c.comment_uuid + ) if lecturer_id is not None: - result.comments = [comment for comment in result.comments if comment.lecturer_id == lecturer_id] + comments_query = comments_query.where(Comment.lecturer_id == lecturer_id) + + if user_id is not None: + comments_query = comments_query.where(Comment.user_id == user_id) if unreviewed: if not user: @@ -219,16 +224,31 @@ async def get_comments( ] else: raise ForbiddenAction(Comment) + comments_query = comments_query.where(Comment.review_status == ReviewStatus.PENDING) else: - result.comments = [comment for comment in result.comments if comment.review_status is ReviewStatus.APPROVED] - - result.comments = result.comments[offset : limit + offset] + comments_query = comments_query.where(Comment.review_status == ReviewStatus.APPROVED) if "create_ts" in order_by: - result.comments.sort(key=lambda comment: comment.create_ts, reverse=True) - result.total = len(result.comments) - result.comments = [comment_validator.model_validate(comment) for comment in result.comments] - result.comments.sort(key=lambda comment: comment.create_ts, reverse=True) + comments_query = comments_query.order_by(Comment.create_ts.desc()) + + comments_query = comments_query.offset(offset).limit(limit) + + comments_with_likes = db.session.execute(comments_query).all() + + if not comments_with_likes: + raise ObjectNotFound(Comment, 'all') + # добавляем лайки к комментам + comments = [] + for comment, like_count in comments_with_likes: + comment.like_count = like_count + comments.append(CommentGet.model_validate(comment)) + + result = CommentGetAll( + limit=limit, + offset=offset, + total=len(comments), + comments=comments, + ) return result diff --git a/rating_api/routes/like.py b/rating_api/routes/like.py new file mode 100644 index 0000000..d795708 --- /dev/null +++ b/rating_api/routes/like.py @@ -0,0 +1,65 @@ +import datetime +from typing import Literal +from uuid import UUID + +import aiohttp +from auth_lib.fastapi import UnionAuth +from fastapi import APIRouter, Depends, Query +from fastapi_sqlalchemy import db + +from rating_api.exceptions import AlreadyExists, ForbiddenAction, ObjectNotFound +from rating_api.models import Comment, CommentLike, Lecturer, LecturerUserComment, ReviewStatus +from rating_api.schemas.base import StatusResponseModel +from rating_api.schemas.models import LikeGet +from rating_api.settings import Settings, get_settings + + +settings: Settings = get_settings() +like = APIRouter(prefix="/like", tags=["Like"]) + + +@like.post("/{comment_uuid}", response_model=LikeGet) +async def create_like(comment_uuid, user=Depends(UnionAuth())) -> LikeGet: + """ + Создает лайк на коммент + """ + comment: Comment = Comment.query(session=db.session).filter(Comment.uuid == comment_uuid).one_or_none() + if comment is None: + raise ObjectNotFound(Comment, comment_uuid) + + existing_like: CommentLike = ( + CommentLike.query(session=db.session) + .filter( + CommentLike.comment_uuid == comment_uuid, + CommentLike.user_id == user.get('id'), + CommentLike.is_deleted == False, + ) + .one_or_none() + ) + if existing_like: + raise AlreadyExists(CommentLike, comment_uuid) + + new_like = CommentLike.create(session=db.session, user_id=user.get('id'), comment_uuid=comment_uuid) + return LikeGet.model_validate(new_like) + + +@like.delete("/{comment_uuid}", response_model=StatusResponseModel) +async def delete_like(comment_uuid: UUID, user=Depends(UnionAuth())): + """ + Удалить свой лайк на коммент + """ + like = ( + CommentLike.query(session=db.session) + .filter( + CommentLike.comment_uuid == comment_uuid, + CommentLike.user_id == user.get('id'), + CommentLike.is_deleted == False, + ) + .one_or_none() + ) + if not like: + raise ObjectNotFound(CommentLike, comment_uuid) + CommentLike.delete(session=db.session, id=like.id) + return StatusResponseModel( + status="Success", message="Like has been deleted", ru="Лайк на данный комментарий был удален" + ) diff --git a/rating_api/schemas/models.py b/rating_api/schemas/models.py index c591fdf..5b232f5 100644 --- a/rating_api/schemas/models.py +++ b/rating_api/schemas/models.py @@ -20,6 +20,7 @@ class CommentGet(Base): mark_clarity: int mark_general: float lecturer_id: int + like_count: int | None = None class CommentGetWithStatus(Base): @@ -148,3 +149,10 @@ class LecturerPatch(Base): middle_name: str | None = None avatar_link: str | None = None timetable_id: int | None = None + + +class LikeGet(Base): + id: int + user_id: int + comment_uuid: UUID + create_ts: datetime.datetime