From d6739434ad3ddddf1a917d477a497b1541bfc08d Mon Sep 17 00:00:00 2001 From: Paul Zhu <35409183+vinovo@users.noreply.github.com> Date: Mon, 25 Jan 2021 18:19:17 -0800 Subject: [PATCH] Feature/354 comment (#384) * added model for comment * api for like/dislike comment * add permission check for update and delete * downgrade to pyjwt==1.7.1 Co-authored-by: yus252 --- backend/alembic/env.py | 1 + ...4e16c97d06e7_add_table_for_comment_like.py | 43 +++++++ .../78b6d95e9727_add_comment_model.py | 43 +++++++ backend/comments/__init__.py | 0 backend/comments/crud.py | 97 ++++++++++++++++ backend/comments/models.py | 23 ++++ backend/comments/schemas.py | 38 ++++++ backend/router/api.py | 13 ++- backend/router/comments.py | 108 ++++++++++++++++++ backend/router/likes.py | 6 +- backend/stories/models.py | 2 + 11 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/4e16c97d06e7_add_table_for_comment_like.py create mode 100644 backend/alembic/versions/78b6d95e9727_add_comment_model.py create mode 100644 backend/comments/__init__.py create mode 100644 backend/comments/crud.py create mode 100644 backend/comments/models.py create mode 100644 backend/comments/schemas.py create mode 100644 backend/router/comments.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 36eece60..3331d90a 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -9,6 +9,7 @@ from users import models as user_models from NytLiveCounty import models as nyt_models from likes import models as like_models +from comments import models as comment_model # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/backend/alembic/versions/4e16c97d06e7_add_table_for_comment_like.py b/backend/alembic/versions/4e16c97d06e7_add_table_for_comment_like.py new file mode 100644 index 00000000..7812f776 --- /dev/null +++ b/backend/alembic/versions/4e16c97d06e7_add_table_for_comment_like.py @@ -0,0 +1,43 @@ +"""add table for comment like + +Revision ID: 4e16c97d06e7 +Revises: 78b6d95e9727 +Create Date: 2021-01-16 11:51:46.777209 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "4e16c97d06e7" +down_revision = "78b6d95e9727" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "comment_likes", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("like", sa.Boolean(), nullable=True), + sa.Column("comment_id", sa.Integer(), nullable=True), + sa.Column("story_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["comment_id"], ["comments.id"],), + sa.ForeignKeyConstraint(["story_id"], ["stories.id"],), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_comment_likes_id"), "comment_likes", ["id"], unique=False + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_comment_likes_id"), table_name="comment_likes") + op.drop_table("comment_likes") + # ### end Alembic commands ### diff --git a/backend/alembic/versions/78b6d95e9727_add_comment_model.py b/backend/alembic/versions/78b6d95e9727_add_comment_model.py new file mode 100644 index 00000000..4d684581 --- /dev/null +++ b/backend/alembic/versions/78b6d95e9727_add_comment_model.py @@ -0,0 +1,43 @@ +"""add comment model + +Revision ID: 78b6d95e9727 +Revises: edace0efb31c +Create Date: 2021-01-16 10:31:42.706758 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "78b6d95e9727" +down_revision = "edace0efb31c" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "comments", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("text", sa.Text(), nullable=True), + sa.Column("parent", sa.Integer(), nullable=True), + sa.Column("story_id", sa.Integer(), nullable=True), + sa.Column("my_story_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["my_story_id"], ["my_stories.id"],), + sa.ForeignKeyConstraint(["parent"], ["comments.id"],), + sa.ForeignKeyConstraint(["story_id"], ["stories.id"],), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_comments_id"), "comments", ["id"], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_comments_id"), table_name="comments") + op.drop_table("comments") + # ### end Alembic commands ### diff --git a/backend/comments/__init__.py b/backend/comments/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/comments/crud.py b/backend/comments/crud.py new file mode 100644 index 00000000..a636c2a3 --- /dev/null +++ b/backend/comments/crud.py @@ -0,0 +1,97 @@ +from sqlalchemy.orm import Session +from sqlalchemy.sql.expression import and_ + +from . import models, schemas +from stories.crud import update + + +def create_comment(db: Session, comment: schemas.CommentCreate): + db_comment = models.Comment(**comment.dict()) + db.add(db_comment) + db.commit() + db.refresh(db_comment) + + return db_comment + + +def get_comments_by_my_story(db: Session, my_story_id): + return ( + db.query(models.Comment) + .filter(models.Comment.my_story_id == my_story_id) + .all() + ) + + +def update_comment(db: Session, comment_id, comment: schemas.CommentUpdate): + return update(comment_id, comment, models.Comment, db) + + +def delete_comment(db: Session, comment_id): + db.query(models.Comment).filter(models.Comment.id == comment_id).delete() + db.commit() + + +def get_comment(db: Session, comment_id): + db_comment = ( + db.query(models.Comment) + .filter(models.Comment.id == comment_id) + .first() + ) + + return db_comment + + +def get_like_by_comment_and_user(db: Session, comment_id, story_id): + return ( + db.query(models.CommentLike) + .filter( + and_( + models.CommentLike.comment_id == comment_id, + models.CommentLike.story_id == story_id, + ) + ) + .first() + ) + + +def like_comment(db: Session, comment_id, story_id, is_like): + like = schemas.CommentLike( + like=is_like, comment_id=comment_id, story_id=story_id + ) + + db_like = get_like_by_comment_and_user(db, comment_id, story_id) + if db_like: + update(db_like.id, like, models.CommentLike, db) + else: + db_like = models.CommentLike(**like.dict()) + db.add(db_like) + db.commit() + + db.refresh(db_like) + return db_like + + +def count_like(db: Session, comment_id): + like = ( + db.query(models.CommentLike) + .filter( + and_( + models.CommentLike.comment_id == comment_id, + models.CommentLike.like == 1, + ) + ) + .count() + ) + + dislike = ( + db.query(models.CommentLike) + .filter( + and_( + models.CommentLike.comment_id == comment_id, + models.CommentLike.like == 0, + ) + ) + .count() + ) + + return {"like": like, "dislike": dislike} diff --git a/backend/comments/models.py b/backend/comments/models.py new file mode 100644 index 00000000..16cd24ae --- /dev/null +++ b/backend/comments/models.py @@ -0,0 +1,23 @@ +from sqlalchemy import Column, Text, ForeignKey, Integer, Boolean +from database import Base +from sqlalchemy.orm import relationship + + +class Comment(Base): + __tablename__ = "comments" + + text = Column(Text) + parent = Column(Integer, ForeignKey("comments.id")) + story_id = Column(Integer, ForeignKey("stories.id")) + my_story_id = Column(Integer, ForeignKey("my_stories.id")) + + story = relationship("Story", back_populates="comments") + my_story = relationship("MyStory", back_populates="comments") + + +class CommentLike(Base): + __tablename__ = "comment_likes" + + like = Column(Boolean) + comment_id = Column(Integer, ForeignKey("comments.id")) + story_id = Column(Integer, ForeignKey("stories.id")) diff --git a/backend/comments/schemas.py b/backend/comments/schemas.py new file mode 100644 index 00000000..45b3bde7 --- /dev/null +++ b/backend/comments/schemas.py @@ -0,0 +1,38 @@ +from datetime import date +from pydantic import BaseModel +from stories.schemas import Story + + +class CommentUpdate(BaseModel): + text: str + + +class CommentBase(CommentUpdate): + parent: int = None + + +class CommentCreate(CommentBase): + story_id: int + my_story_id: int + + +class Comment(CommentCreate): + id: int + updated_at: date + created_at: date + story: Story = None + + class Config: + orm_mode = True + + +class CommentLikeCreate(BaseModel): + like: bool = None + + +class CommentLike(CommentLikeCreate): + comment_id: int + story_id: int + + class Config: + orm_mode = True diff --git a/backend/router/api.py b/backend/router/api.py index 7adf4894..e98764a7 100644 --- a/backend/router/api.py +++ b/backend/router/api.py @@ -1,6 +1,15 @@ from fastapi import APIRouter -from router import auth, stories, users, symptoms, data, nyt_live_county, likes +from router import ( + auth, + stories, + users, + symptoms, + data, + nyt_live_county, + likes, + comments, +) router = APIRouter() @@ -19,3 +28,5 @@ ) router.include_router(likes.router, prefix="/likes", tags=["likes"]) + +router.include_router(comments.router, prefix="/comments") diff --git a/backend/router/comments.py b/backend/router/comments.py new file mode 100644 index 00000000..485bfe33 --- /dev/null +++ b/backend/router/comments.py @@ -0,0 +1,108 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from starlette.responses import JSONResponse + +from auth import main +from stories import schemas as stories_schemas +from database import get_db +from comments import crud, schemas +from router.stories import check_permissions + +router = APIRouter() + + +@router.post("/my_stories/{my_story_id}", response_model=schemas.Comment) +def create_comment( + my_story_id: int, + comment: schemas.CommentBase, + current_story: stories_schemas.Story = Depends(main.get_current_story), + db: Session = Depends(get_db), +): + if not current_story: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User must first share their story before commenting", + headers={"WWW-Authenticate": "Bearer"}, + ) + + story_id = current_story.id + comment = schemas.CommentCreate( + story_id=story_id, my_story_id=my_story_id, **comment.dict() + ) + comment.story_id = story_id + comment.my_story_id = my_story_id + + return crud.create_comment(db, comment) + + +@router.get("/my_stories/{my_story_id}", response_model=List[schemas.Comment]) +def get_comments_by_my_story(my_story_id: int, db: Session = Depends(get_db)): + return crud.get_comments_by_my_story(db, my_story_id) + + +@router.post("/{comment_id}", response_model=schemas.Comment) +def update_comment( + comment_id: int, + comment: schemas.CommentUpdate, + current_story: stories_schemas.Story = Depends(main.get_current_story), + db: Session = Depends(get_db), +): + db_comment = crud.get_comment(db, comment_id) + check_permissions(current_story, db_comment.story_id) + return crud.update_comment(db, comment_id, comment) + + +@router.delete("/{comment_id}") +def delete_comment( + comment_id: int, + current_story: stories_schemas.Story = Depends(main.get_current_story), + db: Session = Depends(get_db), +): + db_comment = crud.get_comment(db, comment_id) + check_permissions(current_story, db_comment.story_id) + crud.delete_comment(db, comment_id) + + +@router.get("/{comment_id}", response_model=schemas.Comment) +def get_comment( + comment_id: int, db: Session = Depends(get_db), +): + return crud.get_comment(db, comment_id) + + +@router.post("/{comment_id}/like", response_model=schemas.CommentLike) +def like_comment( + comment_id: int, + dto: schemas.CommentLikeCreate, + current_story: stories_schemas.Story = Depends(main.get_current_story), + db: Session = Depends(get_db), +): + if not current_story: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User must first share their story before " + + "liking or dislike a comment", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return crud.like_comment(db, comment_id, current_story.id, dto.like) + + +@router.get("/{comment_id}/like") +def count_like( + comment_id: int, + current_story: stories_schemas.Story = Depends(main.get_current_story), + db: Session = Depends(get_db), +): + d = crud.count_like(db, comment_id) + db_like = ( + crud.get_like_by_comment_and_user(db, comment_id, current_story.id) + if current_story + else None + ) + + like_by_me = db_like.like if db_like else None + d["like_by_me"] = like_by_me + + return JSONResponse(d, status_code=200,) diff --git a/backend/router/likes.py b/backend/router/likes.py index 3acf77ca..8b6a0370 100644 --- a/backend/router/likes.py +++ b/backend/router/likes.py @@ -59,7 +59,11 @@ def get_like_count( ): like_count = crud.get_like_count(db, my_story_id) dislike_count = crud.get_dislike_count(db, my_story_id) - is_like_by_me = crud.is_like_by(db, my_story_id, current_story.id) + is_like_by_me = ( + crud.is_like_by(db, my_story_id, current_story.id) + if current_story + else None + ) return JSONResponse( { diff --git a/backend/stories/models.py b/backend/stories/models.py index e730773c..75f9c04e 100644 --- a/backend/stories/models.py +++ b/backend/stories/models.py @@ -41,6 +41,7 @@ class Story(Base): travels = relationship("Travel", lazy="select") close_contacts = relationship("CloseContact", lazy="select") my_stories = relationship("MyStory", lazy="select") + comments = relationship("Comment", lazy="select") @property def medical_conditions(self): @@ -101,3 +102,4 @@ class MyStory(Base): story_id = Column(Integer, ForeignKey("stories.id")) story = relationship("Story", back_populates="my_stories") + comments = relationship("Comment", lazy="select")