Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allowing public comments (#313) #442

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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
48 changes: 48 additions & 0 deletions backend/alembic/versions/21dd979edd2b_init_comments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""init comments

Revision ID: 21dd979edd2b
Revises: 850ccab0221d
Create Date: 2024-02-06 11:37:46.508483+01:00

"""
import fastapi_users_db_sqlalchemy.generics # noqa
import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision = "21dd979edd2b"
down_revision = "850ccab0221d"
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"comments",
sa.Column("id", fastapi_users_db_sqlalchemy.generics.GUID(), nullable=False),
sa.Column("created", sa.DateTime(), nullable=False),
sa.Column("updated", sa.DateTime(), nullable=False),
sa.Column("user", sa.Uuid(), nullable=False),
sa.Column(
"obj_type", sa.Enum("seqvar", "strucvar", "gene", name="commenttypes"), nullable=False
),
sa.Column("obj_id", sa.String(length=255), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("public", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(["user"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user", "obj_type", "obj_id", name="uq_comment"),
)
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 ###

sa.Enum(name="commenttypes").drop(op.get_bind(), checkfirst=False)
2 changes: 2 additions & 0 deletions backend/alembic/versions/27c3977494f7_init_bookmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,5 @@ def downgrade():
op.drop_index(op.f("ix_bookmarks_id"), table_name="bookmarks")
op.drop_table("bookmarks")
# ### end Alembic commands ###

sa.Enum(name="bookmarktypes").drop(op.get_bind(), checkfirst=False)
2 changes: 2 additions & 0 deletions backend/app/api/api_v1/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
bookmarks,
caseinfo,
clinvarsub,
comments,
utils,
)
from app.core.auth import auth_backend_bearer, auth_backend_cookie, fastapi_users
Expand All @@ -21,6 +22,7 @@
api_router.include_router(acmgseqvar.router, prefix="/acmgseqvar", tags=["acmgseqvar"])
api_router.include_router(adminmsgs.router, prefix="/adminmsgs", tags=["adminmsgs"])
api_router.include_router(bookmarks.router, prefix="/bookmarks", tags=["bookmarks"])
api_router.include_router(comments.router, prefix="/comments", tags=["comments"])
api_router.include_router(utils.router, prefix="/utils", tags=["utils"])
api_router.include_router(caseinfo.router, prefix="/caseinfo", tags=["caseinfo"])
api_router.include_router(clinvarsub.router, prefix="/clinvarsub", tags=["clinvarsub"])
Expand Down
24 changes: 2 additions & 22 deletions backend/app/api/api_v1/endpoints/clinvarsub.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,23 @@
"""Endpoints for the ClinVar submission API."""

import logging
from typing import Generic, Optional, TypeVar
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException
from fastapi_pagination.bases import CursorRawParams
from fastapi_pagination.cursor import CursorPage, CursorParams
from fastapi_pagination.ext.sqlalchemy import paginate
from sqlalchemy.ext.asyncio import AsyncSession

from app import crud, schemas, worker
from app.api import deps
from app.api.deps import current_active_user
from app.etc.fastapi_pagination import TotalCursorPage
from app.models.clinvarsub import SubmissionActivityStatus
from app.models.user import User

logger = logging.getLogger(__name__)

router = APIRouter()

T = TypeVar("T")


class TotalCursorParams(CursorParams):
"""Cursor params with total count."""

def to_raw_params(self) -> CursorRawParams:
params = super().to_raw_params()
params.include_total = True

return params


class TotalCursorPage(CursorPage[T], Generic[T]):
"""Cursor page with total count."""

__params_type__ = TotalCursorParams


# -- SumbmittingOrg -----------------------------------------------------------


Expand Down
184 changes: 184 additions & 0 deletions backend/app/api/api_v1/endpoints/comments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
from typing import Annotated

from fastapi import APIRouter, Depends, Header, HTTPException, Response
from fastapi_pagination.ext.sqlalchemy import paginate
from sqlalchemy.ext.asyncio import AsyncSession

from app import crud, schemas
from app.api import deps
from app.api.deps import (
current_active_superuser,
current_active_user,
current_verified_user,
optional_current_user,
)
from app.etc.fastapi_pagination import TotalCursorPage
from app.models.user import User

router = APIRouter()


@router.post("/create", response_model=schemas.CommentCreate)
async def create_comment(
comment: schemas.CommentCreate,
db: AsyncSession = Depends(deps.get_db),
user: User = Depends(current_verified_user),
):
"""
Create a new comment.

:param comment: comment to create
:type comment: dict or :class:`.schemas.CommentCreate`
:return: comment
:rtype: dict
"""
comment.user = user.id
return await crud.comment.create(db, obj_in=comment)


@router.get(
"/list/{obj_type}/{obj_id}",
response_model=TotalCursorPage[schemas.CommentRead],
)
async def list_comments(
obj_type: str,
obj_id: str,
db: AsyncSession = Depends(deps.get_db),
user: User | None = Depends(optional_current_user),
):
"""
List all comments. Available only for superusers.

:
:return: paginated list of comments
"""
query = crud.comment.query_by_object(obj_type=obj_type, obj_id=obj_id, user=user)
return await paginate(db, query)


@router.get(
"/get-by-id",
dependencies=[Depends(current_active_superuser)],
response_model=schemas.CommentRead,
)
async def get_comment(id: str, db: AsyncSession = Depends(deps.get_db)):
"""
Get a comment by id. Available only for superusers.

:param id: id of the comment
:type id: uuid
:return: comment
:rtype: dict
"""
result = await crud.comment.get(db, id=id)
if not result: # pragma: no cover
raise HTTPException(status_code=404, detail="Comment not found")
else:
return result


@router.delete(
"/delete-by-id",
response_model=schemas.CommentRead,
dependencies=[Depends(current_active_superuser)],
)
async def delete_comment(id: str, db: AsyncSession = Depends(deps.get_db)):
"""
Delete a comment. Available for superusers and comment owners.

:param id: id of the comment
:type id: uuid
:return: comment which was deleted
:rtype: dict
"""
result = await crud.comment.remove(db, id=id)
if not result: # pragma: no cover
raise HTTPException(status_code=404, detail="Comment not found")
else:
return result


@router.get("/list", response_model=list[schemas.CommentRead])
async def list_comments_for_user(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(deps.get_db),
user: User = Depends(current_active_user),
):
"""
List comments for a current user.

:param skip: number of comments to skip
:type skip: int
:param limit: maximum number of comments to return
:type limit: int
:return: list of comments
:rtype: list
"""
return await crud.comment.get_multi_by_user(db, user_id=user.id, skip=skip, limit=limit)


@router.get("/get", response_model=schemas.CommentRead)
async def get_comment_for_user(
obj_type: str,
obj_id: str,
db: AsyncSession = Depends(deps.get_db),
user: User = Depends(current_active_user),
user_agent: Annotated[str | None, Header()] = None,
):
"""
Get a comment for a current user by obj_type and obj_id.

:param obj_type: type of object to comment
:type obj_type: str (enum) - "gene", "seqvar", "strucvar"
:param obj_id: id of object to comment
:type obj_id: uuid
:return: comment
:rtype: dict
:raises HTTPException 404: if comment not found
:note: if user_agent is browser, return 204
"""
result = await crud.comment.get_by_user_and_obj(
db, user_id=user.id, obj_type=obj_type, obj_id=obj_id
)
if not result: # pragma: no cover
# if user_agent is browser, return 204, else 404
if user_agent and "Mozilla" in user_agent:
raise HTTPException(status_code=204)
else:
raise HTTPException(status_code=404, detail="Comment not found")
else:
return result


@router.delete("/delete", response_model=schemas.CommentRead)
async def delete_comment_for_user(
obj_type: str,
obj_id: str,
db: AsyncSession = Depends(deps.get_db),
user: User = Depends(current_active_user),
user_agent: Annotated[str | None, Header()] = None,
):
"""
Delete a comment for a current user by obj_type and obj_id.

:param obj_type: type of object to comment
:type obj_type: str (enum) - "gene", "seqvar", "strucvar"
:param obj_id: id of object to comment
:type obj_id: uuid
:return: comment which was deleted
:rtype: dict
:raises HTTPException 404: if comment not found
:note: if user_agent is browser, return 204 Response
"""
comment = await crud.comment.get_by_user_and_obj(
db, user_id=user.id, obj_type=obj_type, obj_id=obj_id
)
if comment:
return await crud.comment.remove(db, id=comment.id)
else: # pragma: no cover
# if user_agent is browser, return 204, else 404
if user_agent and "Mozilla" in user_agent:
return Response(status_code=204)
else:
raise HTTPException(status_code=404, detail="Comment not found")
6 changes: 6 additions & 0 deletions backend/app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
from app.core import auth
from app.db.session import SessionLocal

#: Optional current user.
optional_current_user = auth.fastapi_users.current_user(optional=True)
#: Current user (requires active status).
current_active_user = auth.fastapi_users.current_user(active=True)
#: Current verified user (requires active and verified status).
current_verified_user = auth.fastapi_users.current_user(active=True, verified=True)
#: Current superuser (requires active and superuser status).
current_active_superuser = auth.fastapi_users.current_user(active=True, superuser=True)


Expand Down
5 changes: 4 additions & 1 deletion backend/app/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ def __init__(
super().__init__(user_db, password_helper)

async def on_after_register(self, user: User, request: Request | None = None):
print(f"User {user.id} has registered.")
print(
f"User {user.id} has registered (active={user.is_active}, "
f"verified={user.is_verified}, superuser={user.is_superuser})."
)

async def on_after_forgot_password(
self, user: User, token: str, request: Request | None = None
Expand Down
5 changes: 4 additions & 1 deletion backend/app/crud/__init__.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
from app.crud.acmgseqvar import CrudAcmgSeqVar
from app.crud.base import CrudBase
from app.crud.bookmarks import CrudBookmark
from app.crud.bookmark import CrudBookmark
from app.crud.caseinfo import CrudCaseInfo
from app.crud.clinvarsub import (
CrudClinvarSubmittingOrg,
CrudSubmissionActivity,
CrudSubmissionThread,
)
from app.crud.comment import CrudComment
from app.models.acmgseqvar import AcmgSeqVar
from app.models.adminmsg import AdminMessage
from app.models.bookmark import Bookmark
from app.models.caseinfo import CaseInfo
from app.models.clinvarsub import SubmissionActivity, SubmissionThread, SubmittingOrg
from app.models.comment import Comment
from app.schemas.adminmsg import AdminMessageCreate, AdminMessageUpdate

adminmessage = CrudBase[AdminMessage, AdminMessageCreate, AdminMessageUpdate](AdminMessage)
bookmark = CrudBookmark(Bookmark)
comment = CrudComment(Comment)
caseinfo = CrudCaseInfo(CaseInfo)
acmgseqvar = CrudAcmgSeqVar(AcmgSeqVar)
submittingorg = CrudClinvarSubmittingOrg(SubmittingOrg)
Expand Down
File renamed without changes.
42 changes: 42 additions & 0 deletions backend/app/crud/comment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from operator import or_
from typing import Any, Sequence, Tuple

from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select

from app.crud.base import CrudBase
from app.models.comment import Comment
from app.models.user import User
from app.schemas.comment import CommentCreate, CommentUpdate


class CrudComment(CrudBase[Comment, CommentCreate, CommentUpdate]):
async def get_multi_by_user(
self, session: AsyncSession, *, user_id: Any, skip: int = 0, limit: int = 100
) -> Sequence[Comment]:
query = select(self.model).filter(self.model.user == user_id).offset(skip).limit(limit)
result = await session.execute(query)
return result.scalars().all()

async def get_by_user_and_obj(
self, session: AsyncSession, *, user_id: Any, obj_type: Any, obj_id: Any
) -> Comment | None:
query = select(self.model).filter(
self.model.user == user_id, self.model.obj_type == obj_type, self.model.obj_id == obj_id
)
result = await session.execute(query)
return result.scalars().first()

def query_by_object(
self, *, obj_type: Any, obj_id: Any, user: User | None
) -> Select[Tuple[Comment]]:
return (
select(self.model)
.filter(
self.model.obj_type == obj_type,
self.model.obj_id == obj_id,
or_(self.model.public == True, self.model.user == user.id if user else False),
)
.order_by(self.model.created.desc())
)
Loading
Loading