diff --git a/backend/alembic/versions/21dd979edd2b_init_comments.py b/backend/alembic/versions/21dd979edd2b_init_comments.py new file mode 100644 index 00000000..869e2577 --- /dev/null +++ b/backend/alembic/versions/21dd979edd2b_init_comments.py @@ -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) diff --git a/backend/alembic/versions/27c3977494f7_init_bookmarks.py b/backend/alembic/versions/27c3977494f7_init_bookmarks.py index ba5e9827..e73a192c 100644 --- a/backend/alembic/versions/27c3977494f7_init_bookmarks.py +++ b/backend/alembic/versions/27c3977494f7_init_bookmarks.py @@ -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) diff --git a/backend/app/api/api_v1/api.py b/backend/app/api/api_v1/api.py index 164b4e1a..a9adbb80 100644 --- a/backend/app/api/api_v1/api.py +++ b/backend/app/api/api_v1/api.py @@ -11,6 +11,7 @@ bookmarks, caseinfo, clinvarsub, + comments, utils, ) from app.core.auth import auth_backend_bearer, auth_backend_cookie, fastapi_users @@ -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"]) diff --git a/backend/app/api/api_v1/endpoints/clinvarsub.py b/backend/app/api/api_v1/endpoints/clinvarsub.py index d3b38bbd..b886c9f7 100644 --- a/backend/app/api/api_v1/endpoints/clinvarsub.py +++ b/backend/app/api/api_v1/endpoints/clinvarsub.py @@ -1,17 +1,16 @@ """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 @@ -19,25 +18,6 @@ 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 ----------------------------------------------------------- diff --git a/backend/app/api/api_v1/endpoints/comments.py b/backend/app/api/api_v1/endpoints/comments.py new file mode 100644 index 00000000..149e28b9 --- /dev/null +++ b/backend/app/api/api_v1/endpoints/comments.py @@ -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") diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index bf9fb57c..0c224555 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 7ab8bf3e..227d0c82 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -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 diff --git a/backend/app/crud/__init__.py b/backend/app/crud/__init__.py index 078b44fb..71f71e18 100644 --- a/backend/app/crud/__init__.py +++ b/backend/app/crud/__init__.py @@ -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) diff --git a/backend/app/crud/bookmarks.py b/backend/app/crud/bookmark.py similarity index 100% rename from backend/app/crud/bookmarks.py rename to backend/app/crud/bookmark.py diff --git a/backend/app/crud/comment.py b/backend/app/crud/comment.py new file mode 100644 index 00000000..099ecd31 --- /dev/null +++ b/backend/app/crud/comment.py @@ -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()) + ) diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 078ee264..ba56ade8 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -17,6 +17,8 @@ async def create_user( email: str, password: str, + is_active: bool = True, + is_verified: bool = False, is_superuser: bool = False, get_async_session: Callable[[], AsyncGenerator[AsyncSession, None]] | None = None, ): @@ -32,7 +34,13 @@ async def create_user( async with get_user_db_context(session) as user_db: async with get_user_manager_context(user_db) as user_manager: user = await user_manager.create( - UserCreate(email=email, password=password, is_superuser=is_superuser) + UserCreate( + email=email, + password=password, + is_active=is_active, + is_verified=is_verified, + is_superuser=is_superuser, + ) ) logger.info(f"User created {email}") return user diff --git a/backend/app/etc/fastapi_pagination.py b/backend/app/etc/fastapi_pagination.py new file mode 100644 index 00000000..6d8b7aeb --- /dev/null +++ b/backend/app/etc/fastapi_pagination.py @@ -0,0 +1,23 @@ +"""fastapi-pagination code""" +from typing import Generic, TypeVar + +from fastapi_pagination.bases import CursorRawParams +from fastapi_pagination.cursor import CursorPage, CursorParams + +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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 40c846e3..6a6b5826 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -3,4 +3,5 @@ from app.models.bookmark import Bookmark # noqa from app.models.caseinfo import CaseInfo # noqa from app.models.clinvarsub import SubmissionActivity, SubmissionThread, SubmittingOrg # noqa +from app.models.comment import Comment # noqa from app.models.user import OAuthAccount, User # noqa diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py new file mode 100644 index 00000000..eae1206c --- /dev/null +++ b/backend/app/models/comment.py @@ -0,0 +1,52 @@ +"""Models for comments on variants and genes.""" + +import uuid as uuid_module +from datetime import datetime +from typing import TYPE_CHECKING + +from fastapi_users_db_sqlalchemy.generics import GUID # noqa +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Enum, + ForeignKey, + String, + Text, + UniqueConstraint, + Uuid, +) +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base +from app.models.clinvarsub import default_utcnow +from app.schemas.comment import CommentTypes + +UUID_ID = uuid_module.UUID + + +class Comment(Base): + """Comment of a variant or gene.""" + + __tablename__ = "comments" + + __table_args__ = (UniqueConstraint("user", "obj_type", "obj_id", name="uq_comment"),) + + #: UUID of the comment. + id: Mapped[UUID_ID] = mapped_column( + GUID, primary_key=True, index=True, default=uuid_module.uuid4 + ) + #: User who created the comment. + user = Column(Uuid, ForeignKey("user.id", ondelete="CASCADE"), nullable=False) + #: Type of the commented object. + obj_type: Mapped[CommentTypes] = mapped_column(Enum(CommentTypes), nullable=False) + #: ID of the commented object. + obj_id = Column(String(255), nullable=False) + #: Comment text. + text = Column(Text, nullable=False) + #: Whether the comment is public. + public = Column(Boolean, nullable=False, default=True) + #: Timestamp of creation. + created: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=default_utcnow) + #: Timestamp of last update. + updated: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=default_utcnow) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 9451349f..4983ff55 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -20,6 +20,7 @@ SubmittingOrgUpdate, VariantPresence, ) +from app.schemas.comment import CommentCreate, CommentRead, CommentUpdate # noqa from app.schemas.common import RE_HGNCID, RE_SEQVAR, RE_STRUCVAR # noqa from app.schemas.msg import Msg # noqa from app.schemas.user import UserCreate, UserRead, UserUpdate # noqa diff --git a/backend/app/schemas/comment.py b/backend/app/schemas/comment.py new file mode 100644 index 00000000..85ac3121 --- /dev/null +++ b/backend/app/schemas/comment.py @@ -0,0 +1,56 @@ +import re +from enum import Enum +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, model_validator + +from app.schemas import common +from app.schemas.common import CommentableId + + +class CommentTypes(Enum): + seqvar = "seqvar" + strucvar = "strucvar" + gene = "gene" + + +class CommentBase(BaseModel): + user: UUID | None = None + obj_type: CommentTypes + obj_id: CommentableId + text: str + public: bool = True + + @model_validator(mode="after") + def check_obj_type_id(self): + if self.obj_type == CommentTypes.seqvar: + assert re.match(common.RE_SEQVAR, self.obj_id), "obj_id is not a valid seqvar" + elif self.obj_type == CommentTypes.strucvar: + assert re.match(common.RE_STRUCVAR, self.obj_id), "obj_id is not a valid strucvar" + elif self.obj_type == CommentTypes.gene: + assert re.match(common.RE_HGNCID, self.obj_id), "obj_id is not a valid HGNC ID" + else: + assert False, "unknown obj_type" + return self + + +class CommentCreate(CommentBase): + pass + + +class CommentUpdate(CommentBase): + pass + + +class CommentInDbBase(CommentBase): + model_config = ConfigDict(from_attributes=True) + + id: UUID + + +class CommentRead(CommentInDbBase): + pass + + +class CommentInDb(CommentInDbBase): + pass diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py index c9453853..69851844 100644 --- a/backend/app/schemas/common.py +++ b/backend/app/schemas/common.py @@ -35,3 +35,8 @@ BookmarkableId: TypeAlias = constr( # type: ignore min_length=1, strip_whitespace=True, pattern=f"{RE_SEQVAR}|{RE_STRUCVAR}|{RE_HGNCID}" ) + +#: Type for a commentable object. +CommentableId: TypeAlias = constr( # type: ignore + min_length=1, strip_whitespace=True, pattern=f"{RE_SEQVAR}|{RE_STRUCVAR}|{RE_HGNCID}" +) diff --git a/backend/tests/api/api_v1/test_comments.py b/backend/tests/api/api_v1/test_comments.py new file mode 100644 index 00000000..968c53a7 --- /dev/null +++ b/backend/tests/api/api_v1/test_comments.py @@ -0,0 +1,643 @@ +import uuid + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.models.user import User +from tests.conftest import ObjNames, UserChoice + +#: Shortcut for regular user. +REGUL = UserChoice.REGULAR +#: Shortcut for verified user. +VERIF = UserChoice.VERIFIED +#: Shortcut for superuser. +SUPER = UserChoice.SUPERUSER + +# ------------------------------------------------------------------------------ +# /api/v1/comments/create +# ------------------------------------------------------------------------------ + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(VERIF, VERIF)], indirect=True) +async def test_create_comment( + db_session: AsyncSession, client_user: TestClient, test_user: User, obj_names: ObjNames +): + """Test creating a comment as regular user.""" + _ = db_session + # act: + response = client_user.post( + f"{settings.API_V1_STR}/comments/create/", + json={"obj_type": "gene", "obj_id": obj_names.gene[0], "text": "This is a test comment."}, + ) + # assert: + assert response.status_code == 200 + assert response.json()["obj_type"] == "gene" + assert response.json()["obj_id"] == obj_names.gene[0] + assert response.json()["user"] == str(test_user.id) + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(SUPER, SUPER)], indirect=True) +async def test_create_comment_superuser( + db_session: AsyncSession, client_user: TestClient, test_user: User, obj_names: ObjNames +): + """Test creating a comment as superuser.""" + _ = db_session + # act: + response = client_user.post( + f"{settings.API_V1_STR}/comments/create/", + json={"obj_type": "gene", "obj_id": obj_names.gene[0], "text": "This is a test comment."}, + ) + # assert: + assert response.status_code == 200 + assert response.json()["obj_type"] == "gene" + assert response.json()["obj_id"] == obj_names.gene[0] + assert response.json()["user"] == str(test_user.id) + + +@pytest.mark.anyio +async def test_create_comment_anon( + db_session: AsyncSession, client: TestClient, obj_names: ObjNames +): + """Test creating a comment as anonymous user.""" + _ = db_session + # act: + response = client.post( + f"{settings.API_V1_STR}/comments/create/", + json={"obj_type": "gene", "obj_id": obj_names.gene[0], "text": "This is a test comment."}, + ) + # assert: + assert response.status_code == 401 + assert response.json() == {"detail": "Unauthorized"} + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(SUPER, SUPER)], indirect=True) +async def test_create_comment_invalid_data( + db_session: AsyncSession, client_user: TestClient, test_user: User, obj_names: ObjNames +): + """Test creating a comment with invalid data.""" + _ = db_session + _ = test_user + # act: + response = client_user.post( + f"{settings.API_V1_STR}/comments/create/", + json={"obj_type": "invalid", "obj_id": "invalid", "text": "This is a test comment."}, + ) + # assert: + assert response.status_code == 422 + + +# ------------------------------------------------------------------------------ +# api/v1/comments/list-all +# ------------------------------------------------------------------------------ + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(VERIF, VERIF)], indirect=True) +async def test_list_all_comments( + db_session: AsyncSession, client_user: TestClient, test_user: User, obj_names: ObjNames +): + """Test listing all comments as regular user.""" + _ = db_session + _ = test_user + # act: + # Create a comment + response_create = client_user.post( + f"{settings.API_V1_STR}/comments/create/", + json={"obj_type": "gene", "obj_id": obj_names.gene[0], "text": "This is a test comment."}, + ) + assert response_create.status_code == 200 # guard assertion + response_list_all = client_user.get(f"{settings.API_V1_STR}/comments/list-all/") + # assert:s + assert response_list_all.status_code == 200 + assert response_list_all.json()[0]["obj_type"] == "gene" + assert response_list_all.json()[0]["obj_id"] == obj_names.gene[0] + assert response_list_all.json()[0]["text"] == "This is a test comment." + assert response_list_all.json()[0]["user"] == str(test_user.id) + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(SUPER, SUPER)], indirect=True) +async def test_list_all_comments_superuser( + db_session: AsyncSession, client_user: TestClient, test_user: User, obj_names: ObjNames +): + """Test listing all comments as superuser.""" + _ = db_session + # act: + # Create a comment + response_create = client_user.post( + f"{settings.API_V1_STR}/comments/create/", + json={"obj_type": "gene", "obj_id": obj_names.gene[0], "text": "This is a test comment."}, + ) + assert response_create.status_code == 200 # guard assertion + response_list_all = client_user.get(f"{settings.API_V1_STR}/comments/list-all/") + # assert: + assert response_list_all.status_code == 200 + assert response_list_all.json()[0]["obj_type"] == "gene" + assert response_list_all.json()[0]["obj_id"] == obj_names.gene[0] + assert response_list_all.json()[0]["user"] == str(test_user.id) + + +@pytest.mark.anyio +async def test_list_all_comments_anon(db_session: AsyncSession, client: TestClient): + """Test listing all comments as anonymous user.""" + _ = db_session + # act: + response = client.get(f"{settings.API_V1_STR}/comments/list-all/") + # assert: + assert response.status_code == 401 + assert response.json() == {"detail": "Unauthorized"} + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(SUPER, SUPER)], indirect=True) +async def test_list_all_no_comments( + db_session: AsyncSession, + client_user: TestClient, + test_user: User, +): + """Test listing all comments as superuser when there are no comments.""" + _ = db_session + _ = test_user + # act: + response = client_user.get(f"{settings.API_V1_STR}/comments/list-all/") + # assert: + assert response.status_code == 200 + assert response.json() == [] + + +# ------------------------------------------------------------------------------ +# api/v1/comments/get-by-id +# ------------------------------------------------------------------------------ + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(VERIF, VERIF)], indirect=True) +async def test_get_comment_by_id( + db_session: AsyncSession, client_user: TestClient, test_user: User, obj_names: ObjNames +): + """Test getting a comment by id as regular user.""" + _ = db_session + _ = test_user + # arrange: + comment_id = uuid.uuid4() + # act: + response = client_user.get(f"{settings.API_V1_STR}/comments/get-by-id?id={comment_id}") + # assert: + assert response.status_code == 401 # Forbidden access should be 403 + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(SUPER, SUPER)], indirect=True) +async def test_get_comment_by_id_superuser( + db_session: AsyncSession, client_user: TestClient, test_user: User, obj_names: ObjNames +): + """Test getting a comment by id as superuser.""" + _ = db_session + _ = test_user + # act: + # Create a comment + response_create = client_user.post( + f"{settings.API_V1_STR}/comments/create/", + json={"obj_type": "gene", "obj_id": obj_names.gene[0], "text": "This is a test comment."}, + ) + assert response_create.status_code == 200 # guard assertion + # Get the comment id + response_list = client_user.get(f"{settings.API_V1_STR}/comments/list/") + comment_id = response_list.json()[0]["id"] + response_get_by_id = client_user.get( + f"{settings.API_V1_STR}/comments/get-by-id?id={comment_id}" + ) + # assert: + assert response_get_by_id.status_code == 200 + assert response_get_by_id.json()["id"] == comment_id + + +@pytest.mark.anyio +async def test_get_comment_by_id_anon(db_session: AsyncSession, client: TestClient): + """Test getting a comment by id as anonymous user.""" + _ = db_session + # arrange: + comment_id = uuid.uuid4() + # act: + response = client.get(f"{settings.API_V1_STR}/comments/get-by-id?id={comment_id}/") + # assert: + assert response.status_code == 401 + assert response.json() == {"detail": "Unauthorized"} + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(SUPER, SUPER)], indirect=True) +async def test_get_comment_by_invalid_id( + db_session: AsyncSession, + client_user: TestClient, + test_user: User, +): + """Test getting a comment by invalid id as superuser.""" + _ = db_session + _ = test_user + # arrange: + comment_id = uuid.uuid4() # Invalid id + # act: + response = client_user.get(f"{settings.API_V1_STR}/comments/get-by-id?id={comment_id}") + # assert: + assert response.status_code == 404 + assert response.json() == {"detail": "Comment not found"} + + +# ------------------------------------------------------------------------------ +# api/v1/comments/delete-by-id +# ------------------------------------------------------------------------------ + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(VERIF, VERIF)], indirect=True) +async def test_delete_comment_by_id( + db_session: AsyncSession, client_user: TestClient, test_user: User, obj_names: ObjNames +): + """Test deleting a comment by id as regular user.""" + _ = db_session + _ = test_user + # act: + # Create a comment + response_create = client_user.post( + f"{settings.API_V1_STR}/comments/create/", + json={"obj_type": "gene", "obj_id": obj_names.gene[0], "text": "This is a test comment."}, + ) + assert response_create.status_code == 200 # guard assertion + # Get the comment id + response_list = client_user.get(f"{settings.API_V1_STR}/comments/list/") + comment_id = response_list.json()[0]["id"] + response_delete_by_id = client_user.delete( + f"{settings.API_V1_STR}/comments/delete-by-id?id={comment_id}" + ) + # Verify that the comment was not deleted + response_list = client_user.get(f"{settings.API_V1_STR}/comments/list/") + # assert: + assert response_delete_by_id.status_code == 401 + assert response_delete_by_id.json() == {"detail": "Unauthorized"} + assert response_list.status_code == 200 + assert response_list.json()[0]["id"] == comment_id + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(SUPER, SUPER)], indirect=True) +async def test_delete_comment_by_id_superuser( + db_session: AsyncSession, client_user: TestClient, test_user: User, obj_names: ObjNames +): + """Test deleting a comment by id as superuser.""" + _ = db_session + _ = test_user + # act: + # Create a comment + response_creat = client_user.post( + f"{settings.API_V1_STR}/comments/create/", + json={"obj_type": "gene", "obj_id": obj_names.gene[0], "text": "This is a test comment."}, + ) + # Get the comment id + response_list = client_user.get(f"{settings.API_V1_STR}/comments/list/") + comment_id = response_list.json()[0]["id"] + response_delete = client_user.delete( + f"{settings.API_V1_STR}/comments/delete-by-id?id={comment_id}" + ) + # Verify that the comment is indeed deleted + response_get_by_id = client_user.get( + f"{settings.API_V1_STR}/comments/get-by-id?id={comment_id}" + ) + # assert: + assert response_creat.status_code == 200 + assert response_delete.status_code == 200 + assert response_delete.json()["id"] == comment_id + assert response_get_by_id.status_code == 404 # Not Found + + +@pytest.mark.anyio +async def test_delete_comment_by_id_anon(db_session: AsyncSession, client: TestClient): + """Test deleting a comment by id as anonymous user.""" + _ = db_session + # arrange: + comment_id = uuid.uuid4() + # act: + response = client.delete(f"{settings.API_V1_STR}/comments/delete-by-id?id={comment_id}/") + # assert: + assert response.status_code == 401 + assert response.json() == {"detail": "Unauthorized"} + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(SUPER, SUPER)], indirect=True) +async def test_delete_comment_by_invalid_id( + db_session: AsyncSession, + client_user: TestClient, + test_user: User, +): + """Test deleting a comment by invalid id as superuser.""" + _ = db_session + _ = test_user + # arrange: + comment_id = uuid.uuid4() + # act: + response = client_user.delete(f"{settings.API_V1_STR}/comments/delete-by-id?id={comment_id}") + # assert: + assert response.status_code == 404 + assert response.json() == {"detail": "Comment not found"} + + +# ------------------------------------------------------------------------------ +# api/v1/comments/list +# ------------------------------------------------------------------------------ + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(VERIF, VERIF)], indirect=True) +async def test_list_comments( + db_session: AsyncSession, client_user: TestClient, test_user: User, obj_names: ObjNames +): + """Test listing comments as regular user.""" + _ = db_session + # arrange: + # Create a comment + response_create = client_user.post( + f"{settings.API_V1_STR}/comments/create/", + json={"obj_type": "gene", "obj_id": obj_names.gene[0], "text": "This is a test comment."}, + ) + assert response_create.status_code == 200 # guard assertion + response_list = client_user.get(f"{settings.API_V1_STR}/comments/list/") + # assert: + assert response_list.status_code == 200 + assert response_list.json()[0]["obj_type"] == "gene" + assert response_list.json()[0]["obj_id"] == obj_names.gene[0] + assert response_list.json()[0]["user"] == str(test_user.id) + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(SUPER, SUPER)], indirect=True) +async def test_list_comments_superuser( + db_session: AsyncSession, client_user: TestClient, test_user: User, obj_names: ObjNames +): + """Test listing comments as superuser.""" + _ = db_session + # act: + # Create a comment + response_create = client_user.post( + f"{settings.API_V1_STR}/comments/create/", + json={"obj_type": "gene", "obj_id": obj_names.gene[0], "text": "This is a test comment."}, + ) + assert response_create.status_code == 200 # guard assertion + response_list = client_user.get(f"{settings.API_V1_STR}/comments/list/") + # assert: + assert response_list.status_code == 200 + assert response_list.json()[0]["obj_type"] == "gene" + assert response_list.json()[0]["obj_id"] == obj_names.gene[0] + assert response_list.json()[0]["user"] == str(test_user.id) + + +@pytest.mark.anyio +async def test_list_comments_anon(db_session: AsyncSession, client: TestClient): + """Test listing comments as anonymous user.""" + _ = db_session + # act: + response = client.get(f"{settings.API_V1_STR}/comments/list/") + # assert: + assert response.status_code == 401 + assert response.json() == {"detail": "Unauthorized"} + + +@pytest.mark.anyio +@pytest.mark.parametrize("client_user", [(SUPER, SUPER)], indirect=True) +async def test_list_no_comments( + db_session: AsyncSession, + client_user: TestClient, +): + """Test listing comments as superuser when there are no comments.""" + _ = db_session + # act: + response = client_user.get(f"{settings.API_V1_STR}/comments/list/") + # assert: + assert response.status_code == 200 + assert response.json() == [] + + +# ------------------------------------------------------------------------------ +# api/v1/comments/get +# ------------------------------------------------------------------------------ + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(VERIF, VERIF)], indirect=True) +async def test_get_comment( + db_session: AsyncSession, + client_user: TestClient, + test_user: User, + obj_names: ObjNames, +): + """Test getting a comment as regular user.""" + _ = db_session + # act: + # Create a comment + response_create = client_user.post( + f"{settings.API_V1_STR}/comments/create/", + json={"obj_type": "gene", "obj_id": obj_names.gene[0], "text": "This is a test comment."}, + ) + assert response_create.status_code == 200 # guard assertion + response_get = client_user.get( + f"{settings.API_V1_STR}/comments/get?obj_type=gene&obj_id={obj_names.gene[0]}" + ) + # assert: + assert response_get.status_code == 200 + assert response_get.json()["obj_type"] == "gene" + assert response_get.json()["obj_id"] == obj_names.gene[0] + assert response_get.json()["user"] == str(test_user.id) + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(SUPER, SUPER)], indirect=True) +async def test_get_comment_superuser( + db_session: AsyncSession, client_user: TestClient, test_user: User, obj_names: ObjNames +): + """Test getting a comment as superuser.""" + _ = db_session + # act: + # Create a comment + response_create = client_user.post( + f"{settings.API_V1_STR}/comments/create/", + json={"obj_type": "gene", "obj_id": obj_names.gene[0], "text": "This is a test comment."}, + ) + assert response_create.status_code == 200 # guard assertion + response_get = client_user.get( + f"{settings.API_V1_STR}/comments/get?obj_type=gene&obj_id={obj_names.gene[0]}" + ) + # assert: + assert response_get.status_code == 200 + assert response_get.json()["obj_type"] == "gene" + assert response_get.json()["obj_id"] == obj_names.gene[0] + assert response_get.json()["user"] == str(test_user.id) + + +@pytest.mark.anyio +async def test_get_comment_anon(db_session: AsyncSession, client: TestClient, obj_names: ObjNames): + """Test getting a comment as anonymous user.""" + _ = db_session + # act: + response = client.get( + f"{settings.API_V1_STR}/comments/get?obj_type=gene&obj_id={obj_names.gene[0]}" + ) + # assert: + assert response.status_code == 401 + assert response.json() == {"detail": "Unauthorized"} + + +@pytest.mark.anyio +@pytest.mark.parametrize("client_user", [(SUPER, SUPER)], indirect=True) +async def test_get_no_comments( + db_session: AsyncSession, client_user: TestClient, obj_names: ObjNames +): + """Test getting a comment as superuser when there are no comments.""" + _ = db_session + # act: + response = client_user.get( + f"{settings.API_V1_STR}/comments/get?obj_type=gene&obj_id={obj_names.gene[0]}" + ) + # assert: + # Status code is 404 because we use internal agent + assert response.status_code == 404 + assert response.json() == {"detail": "Comment not found"} + + +@pytest.mark.anyio +@pytest.mark.parametrize("client_user", [(SUPER, SUPER)], indirect=True) +async def test_get_no_comment_with_browser_header( + db_session: AsyncSession, client_user: TestClient, obj_names: ObjNames +): + """ + Test getting a comment as superuser when there are no comments by simulating the browser + behaviour. + """ + _ = db_session + # act: + response = client_user.get( + f"{settings.API_V1_STR}/comments/get?obj_type=gene&obj_id={obj_names.gene[0]}", + headers={"user-agent": "Mozilla/5.0"}, + ) + # assert: + # Status code is 204 because we use browser agent + assert response.status_code == 204 + + +# ------------------------------------------------------------------------------ +# api/v1/comments/delete +# ------------------------------------------------------------------------------ + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(VERIF, VERIF)], indirect=True) +async def test_delete_comment( + db_session: AsyncSession, + client_user: TestClient, + test_user: User, + obj_names: ObjNames, +): + """Test deleting a comment as regular user.""" + _ = db_session + _ = test_user + # act: + # Create a comment + response_create = client_user.post( + f"{settings.API_V1_STR}/comments/create/", + json={"obj_type": "gene", "obj_id": obj_names.gene[0], "text": "This is a test comment."}, + ) + assert response_create.status_code == 200 # guard assertion + response_delete = client_user.delete( + f"{settings.API_V1_STR}/comments/delete?obj_type=gene&obj_id={obj_names.gene[0]}" + ) + # Verify that the comment is indeed deleted + response_get = client_user.get( + f"{settings.API_V1_STR}/comments/get?obj_type=gene&obj_id={obj_names.gene[0]}" + ) + # assert: + assert response_delete.status_code == 200 + assert response_delete.json()["obj_type"] == "gene" + assert response_get.status_code == 404 + + +@pytest.mark.anyio +@pytest.mark.parametrize("test_user, client_user", [(SUPER, SUPER)], indirect=True) +async def test_delete_comment_superuser( + db_session: AsyncSession, client_user: TestClient, test_user: User, obj_names: ObjNames +): + """Test deleting a comment as superuser.""" + _ = db_session + _ = test_user + # act: + # Create a comment + response_create = client_user.post( + f"{settings.API_V1_STR}/comments/create/", + json={"obj_type": "gene", "obj_id": obj_names.gene[0], "text": "This is a test comment."}, + ) + response_delete = client_user.delete( + f"{settings.API_V1_STR}/comments/delete?obj_type=gene&obj_id={obj_names.gene[0]}" + ) + # Verify that the comment is indeed deleted + response_get = client_user.get( + f"{settings.API_V1_STR}/comments/get?obj_type=gene&obj_id={obj_names.gene[0]}" + ) + # assert: + assert response_delete.status_code == 200 + assert response_delete.json()["obj_type"] == "gene" + assert response_get.status_code == 404 + + +@pytest.mark.anyio +async def test_delete_comment_anon( + db_session: AsyncSession, client: TestClient, obj_names: ObjNames +): + """Test deleting a comment as anonymous user.""" + _ = db_session + # act: + response = client.delete( + f"{settings.API_V1_STR}/comments/delete?obj_type=gene&obj_id={obj_names.gene[0]}" + ) + # assert: + assert response.status_code == 401 + assert response.json() == {"detail": "Unauthorized"} + + +@pytest.mark.anyio +@pytest.mark.parametrize("client_user", [(SUPER, SUPER)], indirect=True) +async def test_delete_no_comments( + db_session: AsyncSession, client_user: TestClient, obj_names: ObjNames +): + """Test deleting a comment as superuser when there are no comments.""" + _ = db_session + # act: + response = client_user.delete( + f"{settings.API_V1_STR}/comments/delete?obj_type=gene&obj_id={obj_names.gene[0]}" + ) + # assert: + # Status code is 404 because we use internal agent + assert response.status_code == 404 + assert response.json() == {"detail": "Comment not found"} + + +@pytest.mark.anyio +@pytest.mark.parametrize("client_user", [(SUPER, SUPER)], indirect=True) +async def test_delete_no_comment_with_browser_header( + db_session: AsyncSession, client_user: TestClient, obj_names: ObjNames +): + """ + Test deleting a comment as superuser when there are no comments by simulating the browser + behaviour. + """ + _ = db_session + # act: + response = client_user.delete( + f"{settings.API_V1_STR}/comments/delete?obj_type=gene&obj_id={obj_names.gene[0]}", + headers={"user-agent": "Mozilla/5.0"}, + ) + # assert: + # Status code is 204 because we use browser agent + assert response.status_code == 204 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 779b71fd..e991d721 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -19,7 +19,7 @@ from app import crud from app.api import deps -from app.api.deps import current_active_superuser, current_active_user +from app.api.deps import current_active_superuser, current_active_user, current_verified_user from app.db import init_db, session from app.db.base import Base from app.main import app @@ -93,7 +93,7 @@ def db_engine() -> Iterator[AsyncEngine]: yield engine -@pytest.fixture() +@pytest.fixture async def db_session( db_engine: AsyncEngine, monkeypatch: MonkeyPatch ) -> AsyncGenerator[AsyncSession, None]: @@ -124,7 +124,7 @@ def override_get_db(): await conn.run_sync(Base.metadata.drop_all) -@pytest.fixture(scope="module") +@pytest.fixture(scope="function") def client() -> Iterator[TestClient]: """Fixture with a test client for the FastAPI app.""" with TestClient(app) as c: @@ -148,11 +148,13 @@ class UserChoice(enum.Enum): NONE = "anonymous" #: Regular user REGULAR = "regular" + #: Verified user + VERIFIED = "verified" #: Superuser SUPERUSER = "superuser" -@pytest.fixture() +@pytest.fixture def test_user(db_session: AsyncSession, request: pytest.FixtureRequest) -> User | None: """Create a test user and return it. @@ -170,6 +172,8 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]: init_db.create_user( email="test@example.com", password="password123", + is_active=True, + is_verified=user_choice in [UserChoice.VERIFIED, UserChoice.SUPERUSER], is_superuser=user_choice == UserChoice.SUPERUSER, get_async_session=get_db_session, ) @@ -177,28 +181,28 @@ async def get_db_session() -> AsyncGenerator[AsyncSession, None]: return user -@pytest.fixture() +@pytest.fixture def client_user(test_user: User | None, request: pytest.FixtureRequest): """Create a test client with a test user. Special handling for ``request.param`` (type ``TestUser``). """ # Get the user type from the request, defaulting to regular and early return for anonymous. - user: UserChoice = getattr(request, "param", UserChoice.REGULAR) + user_choice: UserChoice = getattr(request, "param", UserChoice.REGULAR) app.dependency_overrides[current_active_user] = lambda: test_user - - if test_user is not None: - app.dependency_overrides[current_active_user] = lambda: test_user - - if user == UserChoice.SUPERUSER: - app.dependency_overrides[current_active_superuser] = lambda: test_user + if user_choice in [UserChoice.VERIFIED, UserChoice.SUPERUSER]: + app.dependency_overrides[current_verified_user] = lambda: test_user + if user_choice == UserChoice.SUPERUSER: + app.dependency_overrides[current_active_superuser] = lambda: test_user client = TestClient(app) yield client app.dependency_overrides.pop(current_active_user, None) - if user == UserChoice.SUPERUSER: + if user_choice in [UserChoice.VERIFIED, UserChoice.SUPERUSER]: + app.dependency_overrides.pop(current_verified_user, None) + if user_choice == UserChoice.SUPERUSER: app.dependency_overrides.pop(current_active_superuser, None) diff --git a/backend/tests/crud/test_comment.py b/backend/tests/crud/test_comment.py new file mode 100644 index 00000000..883f5125 --- /dev/null +++ b/backend/tests/crud/test_comment.py @@ -0,0 +1,75 @@ +import uuid + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app import crud +from app.schemas.comment import CommentCreate, CommentTypes +from tests.conftest import ObjNames + + +@pytest.fixture +def bookmark_create(obj_names: ObjNames) -> CommentCreate: + """Fixture for creating a comment.""" + return CommentCreate( + obj_type=CommentTypes.gene, + obj_id=obj_names.gene[0], + user=uuid.uuid4(), + text="This is a test comment.", + ) + + +@pytest.mark.anyio +async def test_create_get_bookmark(db_session: AsyncSession, bookmark_create: CommentCreate): + """Test creating and retrieving a comment.""" + # act: + bookmark_postcreate = await crud.comment.create(session=db_session, obj_in=bookmark_create) + stored_item = await crud.comment.get(session=db_session, id=bookmark_postcreate.id) + # assert: + assert stored_item + assert bookmark_postcreate.id == stored_item.id + assert bookmark_postcreate.obj_type == stored_item.obj_type + assert bookmark_postcreate.obj_id == stored_item.obj_id + + +@pytest.mark.anyio +async def test_delete_bookmark(db_session: AsyncSession, bookmark_create: CommentCreate): + """Test deleting a comment.""" + # act: + bookmark_postcreate = await crud.comment.create(session=db_session, obj_in=bookmark_create) + # assert: + await crud.comment.remove(session=db_session, id=bookmark_postcreate.id) + + +@pytest.mark.anyio +async def test_get_multi_by_user(db_session: AsyncSession, bookmark_create: CommentCreate): + """Test retrieving multiple bookmarks by user.""" + # act: + bookmark_postcreate = await crud.comment.create(session=db_session, obj_in=bookmark_create) + stored_items = await crud.comment.get_multi_by_user( + session=db_session, user_id=bookmark_postcreate.user + ) + # assert: + assert stored_items + assert len(stored_items) == 1 + assert bookmark_postcreate.id == stored_items[0].id + assert bookmark_postcreate.obj_type == stored_items[0].obj_type + assert bookmark_postcreate.obj_id == stored_items[0].obj_id + + +@pytest.mark.anyio +async def test_get_by_user_and_obj(db_session: AsyncSession, bookmark_create: CommentCreate): + """Test retrieving a comment by user and object.""" + # act: + bookmark_postcreate = await crud.comment.create(session=db_session, obj_in=bookmark_create) + stored_item = await crud.comment.get_by_user_and_obj( + session=db_session, + user_id=bookmark_postcreate.user, + obj_type=bookmark_postcreate.obj_type, + obj_id=bookmark_postcreate.obj_id, + ) + # assert: + assert stored_item + assert bookmark_postcreate.id == stored_item.id + assert bookmark_postcreate.obj_type == stored_item.obj_type + assert bookmark_postcreate.obj_id == stored_item.obj_id diff --git a/frontend/src/api/caseInfo/api.ts b/frontend/src/api/caseInfo/api.ts index 48674283..98cbc20d 100644 --- a/frontend/src/api/caseInfo/api.ts +++ b/frontend/src/api/caseInfo/api.ts @@ -1,7 +1,6 @@ import { API_V1_BASE_PREFIX } from '@/api/common' -import type { CaseInfo } from '@/stores/caseInfo' -import type { ApiResponse } from './types' +import { type ApiResponse, CaseInfo } from './types' /** * Access to the caseinfo part of the API. @@ -39,18 +38,6 @@ export class CaseInfoClient { * @returns created case information */ async createCaseInfo(caseInfo: CaseInfo): Promise { - const postData = `{ - "pseudonym": "${caseInfo.pseudonym}", - "diseases": ${JSON.stringify(caseInfo.diseases)}, - "hpo_terms": ${JSON.stringify(caseInfo.hpoTerms)}, - "inheritance": "${caseInfo.inheritance}", - "affected_family_members": ${caseInfo.affectedFamilyMembers}, - "sex": "${caseInfo.sex}", - "age_of_onset_month": ${caseInfo.ageOfOnsetMonths}, - "ethincity": "${caseInfo.ethnicity}", - "zygosity": "${caseInfo.zygosity}", - "family_segregation": ${caseInfo.familySegregation} - }` const response = await fetch(`${this.apiBaseUrl}caseinfo/create`, { method: 'POST', mode: 'cors', @@ -59,7 +46,7 @@ export class CaseInfoClient { accept: 'application/json', 'Content-Type': 'application/json' }, - body: postData + body: JSON.stringify(CaseInfo.toJson(caseInfo)) }) return await response.json() } @@ -71,18 +58,6 @@ export class CaseInfoClient { * @returns updated case information */ async updateCaseInfo(caseInfo: CaseInfo): Promise { - const postData = `{ - "pseudonym": "${caseInfo.pseudonym}", - "diseases": ${JSON.stringify(caseInfo.diseases)}, - "hpo_terms": ${JSON.stringify(caseInfo.hpoTerms)}, - "inheritance": "${caseInfo.inheritance}", - "affected_family_members": ${caseInfo.affectedFamilyMembers}, - "sex": "${caseInfo.sex}", - "age_of_onset_month": ${caseInfo.ageOfOnsetMonths}, - "ethincity": "${caseInfo.ethnicity}", - "zygosity": "${caseInfo.zygosity}", - "family_segregation": ${caseInfo.familySegregation} - }` const response = await fetch(`${this.apiBaseUrl}caseinfo/update`, { method: 'PATCH', mode: 'cors', @@ -91,7 +66,7 @@ export class CaseInfoClient { accept: 'application/json', 'Content-Type': 'application/json' }, - body: postData + body: JSON.stringify(CaseInfo.toJson(caseInfo)) }) return await response.json() } diff --git a/frontend/src/api/caseInfo/types.ts b/frontend/src/api/caseInfo/types.ts index 039f8bb8..b5045d3b 100644 --- a/frontend/src/api/caseInfo/types.ts +++ b/frontend/src/api/caseInfo/types.ts @@ -1,4 +1,4 @@ -import type { HpoTerm } from '@/ext/reev-frontend-lib/src/api/viguno/types' +import { HpoTerm, HpoTerm$Api } from '@/ext/reev-frontend-lib/src/api/viguno/types' import type { OmimTerm } from '@/ext/reev-frontend-lib/src/pbs/annonars/genes/base' /** The storage mode. */ @@ -56,17 +56,21 @@ export interface CaseInfo$Api { diseases: any[] // Replace with the actual type from your API ethinicity: string family_segregation: boolean | null - hpo_terms: any[] // Replace with the actual type from your API - id: string + hpo_terms: HpoTerm$Api[] + id?: string inheritance: string | null pseudonym: string sex: string | null - user: string + user?: string zygosity: string } /** Interface for the case data, for storage and API. */ export interface CaseInfo { + /* CaseInfo UUID, if any. */ + id?: string + /* Owner UUID, if any. */ + user?: string /* The case pseudonym. */ pseudonym: string /* Orphanet / OMIM disease(s). */ @@ -90,14 +94,16 @@ export interface CaseInfo { } /** - * Helper class for converting from CaseInfo$Api to CaseInfo. + * Helper class for converting between CaseInfo$Api and CaseInfo. */ export class CaseInfo$Type { fromJson(apiResponse: CaseInfo$Api): CaseInfo { return { + id: apiResponse.id, + user: apiResponse.user, pseudonym: apiResponse.pseudonym, diseases: apiResponse.diseases, - hpoTerms: apiResponse.hpo_terms, + hpoTerms: apiResponse.hpo_terms.map(HpoTerm.fromJson), inheritance: apiResponse.inheritance as Inheritance, affectedFamilyMembers: apiResponse.affected_family_members, sex: apiResponse.sex as Sex, @@ -107,10 +113,27 @@ export class CaseInfo$Type { familySegregation: apiResponse.family_segregation } } + + toJson(caseInfo: CaseInfo): CaseInfo$Api { + return { + id: caseInfo.id, + user: caseInfo.user, + pseudonym: caseInfo.pseudonym, + diseases: caseInfo.diseases, + hpo_terms: caseInfo.hpoTerms.map(HpoTerm.toJson), + inheritance: caseInfo.inheritance, + affected_family_members: caseInfo.affectedFamilyMembers, + sex: caseInfo.sex, + age_of_onset_month: caseInfo.ageOfOnsetMonths, + ethinicity: caseInfo.ethnicity, + zygosity: caseInfo.zygosity, + family_segregation: caseInfo.familySegregation + } + } } /** - * Helper instance for converting from CaseInfo$Api to CaseInfo. + * Helper instance for converting between CaseInfo$Api and CaseInfo. */ export const CaseInfo = new CaseInfo$Type() diff --git a/frontend/src/api/comments/api.spec.ts b/frontend/src/api/comments/api.spec.ts new file mode 100644 index 00000000..379fcdaf --- /dev/null +++ b/frontend/src/api/comments/api.spec.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import createFetchMock from 'vitest-fetch-mock' + +import { type Comment$Api } from '@/api/comments/types' + +import { CommentsClient } from '../comments' + +const fetchMocker = createFetchMock(vi) + +/** Example Comment data */ +const mockComments: Comment$Api[] = [ + { + user: '2c0a153e-5e8c-11ee-8c99-0242ac120002', + obj_type: 'seqvar', + obj_id: 'HGNC:1100', + id: '2c0a153e-5e8c-11ee-8c99-0242ac120001', + text: 'This is a comment', + public: true + } +] + +describe.concurrent('Comments Client', () => { + beforeEach(() => { + fetchMocker.enableMocks() + fetchMocker.resetMocks() + }) + + it('fetches comments correctly', async () => { + // arrange: + fetchMocker.mockResponse((req) => { + if (req.url.includes('users/me')) { + return Promise.resolve(JSON.stringify({ id: '2c0a153e-5e8c-11ee-8c99-0242ac120002' })) + } else if (req.url.includes('comments/list')) { + return Promise.resolve(JSON.stringify(mockComments)) + } + return Promise.resolve(JSON.stringify({ status: 400 })) + }) + + // act: + const client = new CommentsClient() + const result = await client.fetchComments() + + // assert: + expect(result).toEqual(mockComments) + }) + + it('fails to fetch comments', async () => { + // arrange: + fetchMocker.mockResponse((req) => { + if (req.url.includes('users/me')) { + return Promise.resolve(JSON.stringify({ id: '2c0a153e-5e8c-11ee-8c99-0242ac120002' })) + } else if (req.url.includes('comments/list')) { + return Promise.resolve(JSON.stringify({ status: 500 })) + } + return Promise.resolve(JSON.stringify({ status: 400 })) + }) + + // act: + const client = new CommentsClient() + const result = await client.fetchComments() + + // assert: + expect(result).toEqual({ status: 500 }) + }) + + it('fetches comment correctly', async () => { + // arrange: + fetchMocker.mockResponse(JSON.stringify(mockComments[0])) + + // act: + const client = new CommentsClient() + const result = await client.fetchComment('seqvar', 'HGNC:1100') + + // assert: + expect(result).toEqual(mockComments[0]) + }) + + it('fails to fetch comment', async () => { + // arrange: + fetchMocker.mockResponse(JSON.stringify({ detail: 'Internal Server Error' }), { status: 500 }) + + // act: + const client = new CommentsClient() + const result = await client.fetchComment('seqvar', 'HGNC:1100') + + // assert: + expect(result).toEqual({ detail: 'Internal Server Error' }) + }) + + it('creates comment correctly', async () => { + // arrange: + fetchMocker.mockResponse(JSON.stringify({})) + + // act: + const client = new CommentsClient() + const result = await client.createComment('seqvar', 'HGNC:1100') + + // assert: + expect(result).toEqual({}) + }) + + it('fails to create comment', async () => { + // arrange: + fetchMocker.mockResponse(JSON.stringify({ detail: 'Internal Server Error' }), { status: 500 }) + + // act: + const client = new CommentsClient() + const result = await client.createComment('seqvar', 'HGNC:1100') + + // assert: + expect(result).toEqual({ detail: 'Internal Server Error' }) + }) + + it('deletes comment correctly', async () => { + // arrange: + fetchMocker.mockResponse(JSON.stringify({})) + + // act: + const client = new CommentsClient() + const result = await client.deleteComment('seqvar', 'HGNC:1100') + + // assert: + expect(result).toEqual({}) + }) + + it('fails to delete comment', async () => { + // arrange: + fetchMocker.mockResponse(JSON.stringify({ detail: 'Internal Server Error' }), { status: 500 }) + + // act: + const client = new CommentsClient() + const result = await client.deleteComment('seqvar', 'HGNC:1100') + + // assert: + expect(result).toEqual({ detail: 'Internal Server Error' }) + }) +}) diff --git a/frontend/src/api/comments/api.ts b/frontend/src/api/comments/api.ts new file mode 100644 index 00000000..a23976ab --- /dev/null +++ b/frontend/src/api/comments/api.ts @@ -0,0 +1,91 @@ +import { API_V1_BASE_PREFIX } from '@/api/common' + +import { CommentType } from './types' + +/** + * Access to the comments part of the API. + */ +export class CommentsClient { + private apiBaseUrl: string + private csrfToken: string | null + private currentUserId: string | null + + constructor(apiBaseUrl?: string, csrfToken?: string) { + this.apiBaseUrl = apiBaseUrl ?? API_V1_BASE_PREFIX + this.csrfToken = csrfToken ?? null + this.currentUserId = null + } + + /** + * Obtains the currently logged in user's comments. + * + * @returns comments list for the current user + */ + async fetchComments(objType: CommentType, objId: string): Promise { + const url = `${this.apiBaseUrl}comments/list/${objType}/${objId}` + const response = await fetch(url, { + method: 'GET', + mode: 'cors', + credentials: 'include' + }) + return await response.json() + } + + /** + * Obtains the currently logged in user's comment for the given object. + * + * @param obj_type object type, e.g., "seqvar" + * @param obj_id object ID, e.g., "HGNC:1100" + * @returns comment for the current user + */ + async fetchComment(obj_type: string, obj_id: string): Promise { + const url = `${this.apiBaseUrl}comments/get?obj_type=${obj_type}&obj_id=${obj_id}` + const response = await fetch(url, { + method: 'GET', + mode: 'cors', + credentials: 'include' + }) + if (response.status === 204) { + return null + } + return await response.json() + } + + /** + * Creates a comment for the current user. + * + * @param obj_type object type, e.g., "seqvar" + * @param obj_id object ID, e.g., "HGNC:1100" + * @returns created comment + */ + async createComment(obj_type: string, obj_id: string): Promise { + const response = await fetch(`${this.apiBaseUrl}comments/create`, { + method: 'POST', + mode: 'cors', + credentials: 'include', + headers: { + accept: 'application/json', + 'Content-Type': 'application/json' + }, + body: `{"obj_type": "${obj_type}", "obj_id": "${obj_id}"}` + }) + return await response.json() + } + + /** + * Deletes a comment for the current user. + * + * @param obj_type object type, e.g., "seqvar" + * @param obj_id object ID, e.g., "HGNC:1100" + * @returns deleted comment + */ + async deleteComment(obj_type: string, obj_id: string): Promise { + const url = `${this.apiBaseUrl}comments/delete?obj_type=${obj_type}&obj_id=${obj_id}` + const response = await fetch(url, { + method: 'DELETE', + mode: 'cors', + credentials: 'include' + }) + return await response.json() + } +} diff --git a/frontend/src/api/comments/index.ts b/frontend/src/api/comments/index.ts new file mode 100644 index 00000000..39a2c95b --- /dev/null +++ b/frontend/src/api/comments/index.ts @@ -0,0 +1,2 @@ +export * from './api' +export * from './types' diff --git a/frontend/src/api/comments/types.ts b/frontend/src/api/comments/types.ts new file mode 100644 index 00000000..510b92e1 --- /dev/null +++ b/frontend/src/api/comments/types.ts @@ -0,0 +1,62 @@ +/** Allowed values for comment types. */ +export type CommentType = 'seqvar' | 'strucvar' | 'gene' + +/** Interface for comments as returned by the API. */ +export interface Comment$Api { + /** The ID of the comment itself, only set when fetching. */ + id?: string + /** The owner of the comment, only set when fetching. */ + user?: string + /** Type of the comment. */ + obj_type: string + /** The commented object identifier. */ + obj_id: string + /** Whether the comment is public. */ + public?: boolean + /** The comment text. */ + text: string +} + +/** Interface for comments. */ +export interface Comment { + /** The ID of the comment itself. */ + id?: string + /** The owner of the comment. */ + user?: string + /** Type of the comment. */ + objType: CommentType + /** The commented object identifier. */ + objId: string + /** Whether the comment is public. */ + public: boolean + /** The comment text. */ + text: string +} + +/** Helper type for converting between `Comment$Api` and `Comment`. */ +class Comment$Type { + fromJson(api: Comment$Api): Comment { + return { + id: api.id, + user: api.user, + objType: api.obj_type as CommentType, + objId: api.obj_id, + public: api.public ?? false, + text: api.text + } + } + + toJson(comment: Comment): Comment$Api { + return { + id: comment.id, + user: comment.user, + obj_type: comment.objType, + obj_id: comment.objId, + public: comment.public, + text: comment.text + } + } +} + +/** Helper instance for converting between `Comment$Api` and `Comment`. */ +export const Comment = new Comment$Type() diff --git a/frontend/src/components/GeneCommentsCard/CommentEditor.vue b/frontend/src/components/GeneCommentsCard/CommentEditor.vue new file mode 100644 index 00000000..4a90264a --- /dev/null +++ b/frontend/src/components/GeneCommentsCard/CommentEditor.vue @@ -0,0 +1,64 @@ + + + diff --git a/frontend/src/components/GeneCommentsCard/CommentListItem.vue b/frontend/src/components/GeneCommentsCard/CommentListItem.vue new file mode 100644 index 00000000..522241c6 --- /dev/null +++ b/frontend/src/components/GeneCommentsCard/CommentListItem.vue @@ -0,0 +1,22 @@ + + + + diff --git a/frontend/src/components/GeneCommentsCard/GeneCommentsCard.vue b/frontend/src/components/GeneCommentsCard/GeneCommentsCard.vue new file mode 100644 index 00000000..41e8dcc6 --- /dev/null +++ b/frontend/src/components/GeneCommentsCard/GeneCommentsCard.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/stores/comments/index.ts b/frontend/src/stores/comments/index.ts new file mode 100644 index 00000000..16c86332 --- /dev/null +++ b/frontend/src/stores/comments/index.ts @@ -0,0 +1 @@ +export * from './store' diff --git a/frontend/src/stores/comments/store.spec.ts b/frontend/src/stores/comments/store.spec.ts new file mode 100644 index 00000000..a9b8c6ae --- /dev/null +++ b/frontend/src/stores/comments/store.spec.ts @@ -0,0 +1,153 @@ +import { StoreState } from '@bihealth/reev-frontend-lib/stores' +import { Pinia, createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import createFetchMock from 'vitest-fetch-mock' + +import { type Comment$Api } from '@/api/comments/types' + +import { useCommentsStore } from './store' + +const fetchMocker = createFetchMock(vi) + +describe('comments Store', () => { + let pinia: Pinia + beforeEach(() => { + pinia = createPinia() + setActivePinia(pinia) + fetchMocker.enableMocks() + fetchMocker.resetMocks() + }) + + it('should have initial state', () => { + // arrange: + const store = useCommentsStore(pinia) + + // act: nothing to do + + // assert: + expect(store.storeState).toBe(StoreState.Initial) + expect(store.comments).toStrictEqual([]) + }) + + it('should load comments', async () => { + // arrange: + const mockComments: Comment$Api[] = [ + { + user: '2c0a153e-5e8c-11ee-8c99-0242ac120002', + obj_type: 'seqvar', + obj_id: 'HGNC:1100', + id: '2c0a153e-5e8c-11ee-8c99-0242ac120001', + public: true, + text: 'This is a comment' + } + ] + fetchMocker.mockResponse(JSON.stringify(mockComments)) + const store = useCommentsStore() + + // act: + await store.loadComments() + + // assert: + expect(store.storeState).toBe(StoreState.Active) + expect(store.comments).toEqual(mockComments) + }) + + it('should handle error when loading comments', async () => { + // arrange: + // Disable error logging + vi.spyOn(console, 'error').mockImplementation(() => {}) + fetchMocker.mockReject(new Error('Internal Server Error')) + const store = useCommentsStore() + + // act: + await store.loadComments() + + // assert: + expect(store.storeState).toBe(StoreState.Error) + expect(store.comments).toStrictEqual([]) + }) + + it('should delete comment', async () => { + // arrange: + fetchMocker.mockResponse(JSON.stringify({})) + const store = useCommentsStore() + + // act: + await store.deleteComment('seqvar', 'HGNC:1100') + + // assert: + expect(store.storeState).toBe(StoreState.Active) + expect(store.comments).toStrictEqual({}) + }) + + it('should handle error when deleting comment', async () => { + // arrange: + // Disable error logging + vi.spyOn(console, 'error').mockImplementation(() => {}) + fetchMocker.mockReject(new Error('Internal Server Error')) + const store = useCommentsStore() + + // act: + await store.deleteComment('seqvar', 'HGNC:1100') + + // assert: + expect(store.storeState).toBe(StoreState.Error) + expect(store.comments).toStrictEqual([]) + }) + + it('should create comment', async () => { + // arrange: + fetchMocker.mockResponse(JSON.stringify({ comment: 'created' })) + const store = useCommentsStore() + + // act: + await store.createComment('seqvar', 'HGNC:1100') + + // assert: + expect(store.storeState).toBe(StoreState.Active) + expect(store.comments).toStrictEqual({ comment: 'created' }) + }) + + it('should handle error when creating comment', async () => { + // arrange: + // Disable error logging + vi.spyOn(console, 'error').mockImplementation(() => {}) + fetchMocker.mockReject(new Error('Internal Server Error')) + const store = useCommentsStore() + + // act: + await store.createComment('seqvar', 'HGNC:1100') + + // assert: + expect(store.storeState).toBe(StoreState.Error) + expect(store.comments).toStrictEqual([]) + }) + + it('should fetch comment', async () => { + // arrange: + fetchMocker.mockResponse(JSON.stringify({ comment: 'created' })) + const store = useCommentsStore() + store.storeState = StoreState.Active + + // act: + const result = await store.fetchComment('seqvar', 'HGNC:1100') + + // assert: + expect(store.storeState).toBe(StoreState.Active) + expect(result).toStrictEqual({ comment: 'created' }) + }) + + it('should handle error when fetching comment', async () => { + // arrange: + fetchMocker.mockResponse(JSON.stringify({ detail: 'Internal Server Error' }), { status: 500 }) + const store = useCommentsStore() + store.storeState = StoreState.Active + + // act: + await store.fetchComment('seqvar', 'HGNC:1100') + + // assert: + expect(store.storeState).toBe(StoreState.Active) + expect(store.comments).toStrictEqual([]) + }) +}) diff --git a/frontend/src/stores/comments/store.ts b/frontend/src/stores/comments/store.ts new file mode 100644 index 00000000..a28d4504 --- /dev/null +++ b/frontend/src/stores/comments/store.ts @@ -0,0 +1,106 @@ +/** + * Store for information regarding the current user. + */ +import { StoreState } from '@bihealth/reev-frontend-lib/stores' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +import { CommentsClient } from '@/api/comments' +import { Comment, CommentType } from '@/api/comments/types' + +export const useCommentsStore = defineStore('comments', () => { + /* The current store state. */ + const storeState = ref(StoreState.Initial) + + /** The current object type. */ + const objType = ref('gene') + + /** The current object identifier. */ + const objId = ref('') + + /* The comments list for current user and object.. */ + const comments = ref([]) + + /** Initialize the store. */ + const initialize = async (objType$: CommentType, objId$: string, force: boolean = false) => { + // Do not re-load data if the gene symbol is the same + if (objType$ === objType.value && objId$ === objId.value && !force) { + return + } + + // Clear against artifact + clearData() + + // And load the data via API. + await loadComments() + } + + /** Clear store's data and reset into initial state. */ + const clearData = () => { + storeState.value = StoreState.Initial + comments.value = [] + objType.value = 'gene' + objId.value = '' + } + + const loadComments = async () => { + storeState.value = StoreState.Loading + try { + const client = new CommentsClient() + comments.value = await client.fetchComments(objType.value, objId.value) + storeState.value = StoreState.Active + } catch (e) { + storeState.value = StoreState.Error + } + } + + const deleteComment = async (obj_type: string, obj_id: string) => { + storeState.value = StoreState.Loading + try { + const client = new CommentsClient() + await client.deleteComment(obj_type, obj_id) + await loadComments() + } catch (e) { + storeState.value = StoreState.Error + } + } + + const createComment = async (obj_type: string, obj_id: string) => { + storeState.value = StoreState.Loading + try { + const client = new CommentsClient() + await client.createComment(obj_type, obj_id) + await loadComments() + } catch (e) { + storeState.value = StoreState.Error + } + } + + const fetchComment = async (obj_type: string, obj_id: string) => { + try { + const client = new CommentsClient() + const response = await client.fetchComment(obj_type, obj_id) + if (response === null) { + return null + } else if (response.detail === 'Unauthorized') { + storeState.value = StoreState.Error + return null + } else { + return response + } + } catch (e) { + storeState.value = StoreState.Error + return null + } + } + + return { + storeState, + comments, + initialize, + loadComments, + createComment, + fetchComment, + deleteComment + } +}) diff --git a/frontend/src/views/GeneDetailView/GeneDetailView.vue b/frontend/src/views/GeneDetailView/GeneDetailView.vue index bdb62338..310e09fd 100644 --- a/frontend/src/views/GeneDetailView/GeneDetailView.vue +++ b/frontend/src/views/GeneDetailView/GeneDetailView.vue @@ -24,6 +24,7 @@ import { useTheme } from 'vuetify' import BookmarkListItem from '@/components/BookmarkListItem/BookmarkListItem.vue' import FooterDefault from '@/components/FooterDefault/FooterDefault.vue' +import GeneCommentsCard from '@/components/GeneCommentsCard/GeneCommentsCard.vue' import { lookupGene } from '@/lib/query' import { scrollToSection } from '@/lib/utils' import { useCaseInfoStore } from '@/stores/caseInfo' @@ -132,6 +133,7 @@ interface Section { /** Sections in the navigation. */ const SECTIONS: Section[] = [ { id: 'gene-overview', title: 'Overview' }, + { id: 'gene-comments', title: 'Comments' }, { id: 'gene-pathogenicity', title: 'Pathogenicity' }, { id: 'gene-conditions', title: 'Conditions' }, { id: 'gene-expression', title: 'Expression' }, @@ -196,14 +198,18 @@ const SECTIONS: Section[] = [ -
+ + +
+
-
+