diff --git a/backend/alembic/versions/feeb1c78c0a2_add_likes_table.py b/backend/alembic/versions/feeb1c78c0a2_add_likes_table.py new file mode 100644 index 00000000..73cd0485 --- /dev/null +++ b/backend/alembic/versions/feeb1c78c0a2_add_likes_table.py @@ -0,0 +1,57 @@ +"""Add likes table + +Revision ID: feeb1c78c0a2 +Revises: 6ed8b19e393d +Create Date: 2024-09-25 23:55:17.029650 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "feeb1c78c0a2" +down_revision: Union[str, None] = "6ed8b19e393d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + sa.Enum("LIKE", "DISLIKE", name="liketype").create(op.get_bind()) + op.create_table( + "like", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("point_id", sa.Integer(), nullable=True), + sa.Column("analysis_id", sa.Integer(), nullable=False), + sa.Column( + "type", + postgresql.ENUM("LIKE", "DISLIKE", name="liketype", create_type=False), + nullable=False, + ), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["analysis_id"], + ["analysis.id"], + ), + sa.ForeignKeyConstraint( + ["point_id"], + ["point.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("like") + sa.Enum("LIKE", "DISLIKE", name="liketype").drop(op.get_bind()) + # ### end Alembic commands ### diff --git a/backend/src/app.py b/backend/src/app.py index 7299c006..22db8c1e 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -6,6 +6,7 @@ from src.events.router import router as events_router from src.user_questions.router import router as user_questions_router from src.notes.router import router as notes_router, points_router +from src.likes.router import router as likes_router from contextlib import asynccontextmanager import logging @@ -41,3 +42,4 @@ async def lifespan(app: FastAPI): server.include_router(user_questions_router) server.include_router(notes_router) server.include_router(points_router) +server.include_router(likes_router) diff --git a/backend/src/events/dependencies.py b/backend/src/events/dependencies.py index 052594fb..174482de 100644 --- a/backend/src/events/dependencies.py +++ b/backend/src/events/dependencies.py @@ -5,6 +5,7 @@ from src.auth.dependencies import get_current_user from src.common.dependencies import get_session from src.events.models import Analysis, Event, GPQuestion, UserReadEvent +from src.likes.models import Like def retrieve_event( @@ -25,6 +26,9 @@ def retrieve_event( Event.categories, ), selectinload(Event.analysises, Analysis.category), + selectinload( + Event.analysises, Analysis.likes.and_(Like.point_id.is_(None)) + ), selectinload(Event.original_article), ) ) diff --git a/backend/src/events/schemas.py b/backend/src/events/schemas.py index 2da1b874..410d5b50 100644 --- a/backend/src/events/schemas.py +++ b/backend/src/events/schemas.py @@ -2,6 +2,7 @@ from pydantic import BaseModel, ConfigDict from src.categories.schemas import CategoryDTO from src.events.models import ArticleSource +from src.likes.schemas import LikeDTO class ArticleDTO(BaseModel): @@ -40,6 +41,7 @@ class AnalysisDTO(BaseModel): id: int category: CategoryDTO content: str + likes: list[LikeDTO] class GPQuestionDTO(BaseModel): diff --git a/backend/src/likes/models.py b/backend/src/likes/models.py new file mode 100644 index 00000000..e2be3b14 --- /dev/null +++ b/backend/src/likes/models.py @@ -0,0 +1,23 @@ +from enum import Enum +from sqlalchemy import ForeignKey +from src.common.base import Base +from sqlalchemy.orm import Mapped, mapped_column, relationship + + +class LikeType(int, Enum): + LIKE = 1 + DISLIKE = -1 + + +class Like(Base): + __tablename__ = "like" + id: Mapped[int] = mapped_column(primary_key=True) + point_id: Mapped[int] = mapped_column(ForeignKey("point.id"), nullable=True) + analysis_id: Mapped[int] = mapped_column(ForeignKey("analysis.id")) + type: Mapped[LikeType] + user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + + # relationships + point = relationship("Point", backref="likes") + analysis = relationship("Analysis", backref="likes") + user = relationship("User", backref="likes") diff --git a/backend/src/likes/router.py b/backend/src/likes/router.py new file mode 100644 index 00000000..ac4193c4 --- /dev/null +++ b/backend/src/likes/router.py @@ -0,0 +1,50 @@ +from http import HTTPStatus +from typing import Annotated +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from src.auth.dependencies import get_current_user +from src.auth.models import User +from src.common.dependencies import get_session +from src.events.models import Analysis +from src.likes.models import Like +from src.likes.schemas import LikeData +from src.user_questions.models import Point + + +router = APIRouter(prefix="/likes", tags=["likes"]) + + +@router.post("/") +def upsert_like( + data: LikeData, + user: Annotated[User, Depends(get_current_user)], + session=Depends(get_session), +): + # 1. check if the analysis or point exists + analysis = session.get(Analysis, data.analysis_id) + if not analysis: + raise HTTPException(HTTPStatus.NOT_FOUND, "analysis doesn't exist") + + query = select(Like).where(Like.analysis_id == data.analysis_id) + + if data.point_id: + point = session.get(Point, data.point_id) + if not point: + raise HTTPException(HTTPStatus.NOT_FOUND, "point doesn't exist") + query = query.where(Like.point_id == data.point_id) + else: + query = query.where(Like.point_id.is_(None)) + + query = query.where(Like.user_id == user.id) + + # 2. check if the like exists + like = session.scalar(query) + + # 3. create or update + if not like: + like = Like( + point_id=data.point_id, analysis_id=data.analysis_id, user_id=user.id + ) + like.type = data.type + session.add(like) + session.commit() diff --git a/backend/src/likes/schemas.py b/backend/src/likes/schemas.py new file mode 100644 index 00000000..a9268947 --- /dev/null +++ b/backend/src/likes/schemas.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel +from src.likes.models import LikeType + + +class LikeData(BaseModel): + point_id: int | None = None + analysis_id: int + # 1 for Like, -1 for Dislike + type: LikeType + + +class LikeDTO(BaseModel): + point_id: int | None = None + analysis_id: int + # 1 for Like, -1 for Dislike + type: LikeType + user_id: int diff --git a/backend/src/user_questions/router.py b/backend/src/user_questions/router.py index 4e91faa5..ef78039f 100644 --- a/backend/src/user_questions/router.py +++ b/backend/src/user_questions/router.py @@ -2,11 +2,12 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select -from sqlalchemy.orm import with_polymorphic +from sqlalchemy.orm import with_polymorphic, aliased, selectinload from src.auth.dependencies import get_current_user from src.auth.models import User from src.common.dependencies import get_session from src.events.models import Analysis, Event +from src.likes.models import Like from src.notes.models import Note from src.user_questions.models import Answer, Point, UserQuestion from src.user_questions.schemas import CreateUserQuestion, UserQuestionMiniDTO @@ -23,15 +24,40 @@ def get_user_questions( user: Annotated[User, Depends(get_current_user)], session=Depends(get_session), ) -> list[UserQuestionMiniDTO]: + # Create an alias for the Point table to use for the Like condition + point_alias = aliased(Point) user_questions = session.scalars( select(UserQuestion) .where(UserQuestion.user_id == user.id) .join(UserQuestion.answer) .join(Answer.points) - .join(Point.analysises) + .join(point_alias.analysises) .join(Analysis.event) .join(Event.original_article) .join(Analysis.category) + .join(Analysis.likes) + .where(Like.point_id == point_alias.id) + .options( + selectinload( + UserQuestion.answer, + Answer.points.of_type(point_alias), + point_alias.analysises, + Analysis.event, + Event.original_article, + ), + selectinload( + UserQuestion.answer, + Answer.points.of_type(point_alias), + point_alias.analysises, + Analysis.category, + ), + selectinload( + UserQuestion.answer, + Answer.points.of_type(point_alias), + point_alias.analysises, + Analysis.likes, + ), + ) ) return user_questions @@ -52,6 +78,7 @@ def get_user_question( .join(Analysis.event) .join(Event.original_article) .join(Analysis.category) + .join(Analysis.likes) ) if not user_question: raise HTTPException(HTTPStatus.NOT_FOUND) diff --git a/backend/src/user_questions/schemas.py b/backend/src/user_questions/schemas.py index ad8587c1..daac58a7 100644 --- a/backend/src/user_questions/schemas.py +++ b/backend/src/user_questions/schemas.py @@ -1,5 +1,6 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, model_validator from src.events.schemas import MiniEventDTO +from src.likes.schemas import LikeDTO class AnalysisDTO(BaseModel): @@ -7,6 +8,7 @@ class AnalysisDTO(BaseModel): id: int content: str event: MiniEventDTO + likes: list[LikeDTO] class PointMiniDTO(BaseModel): @@ -16,6 +18,15 @@ class PointMiniDTO(BaseModel): body: str analysises: list[AnalysisDTO] + @model_validator(mode="after") + def filter(self): + # i gave up on using the orm to filter the ones relevant to the point + for analysis in self.analysises: + analysis.likes = [ + like for like in analysis.likes if like.point_id == self.id + ] + return self + class AnswerDTO(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/backend/src/utils/models.py b/backend/src/utils/models.py index 42c71f0d..5e6583e2 100644 --- a/backend/src/utils/models.py +++ b/backend/src/utils/models.py @@ -4,3 +4,4 @@ from src.events import models as event_models # noqa: F401 from src.user_questions import models as user_question_models # noqa: F401 from src.notes import models as note_models # noqa: F401 +from src.likes import models as like_models # noqa: F401