From 204f3560d5426a81ab6a1f11727f68c269e4cacc Mon Sep 17 00:00:00 2001 From: anandashin Date: Tue, 14 Jan 2025 14:44:19 +0900 Subject: [PATCH 01/26] =?UTF-8?q?feat:=20collection=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EB=AA=A8=EB=8D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/collection/models.py | 61 ++++++++++++++++++++++++++++ watchapedia/app/movie/models.py | 2 + watchapedia/app/user/models.py | 3 ++ watchapedia/database/__init__.py | 3 +- watchapedia/settings.py | 2 +- 5 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 watchapedia/app/collection/models.py diff --git a/watchapedia/app/collection/models.py b/watchapedia/app/collection/models.py new file mode 100644 index 0000000..1d48d81 --- /dev/null +++ b/watchapedia/app/collection/models.py @@ -0,0 +1,61 @@ +from sqlalchemy import Integer, String, Float, ForeignKey, Boolean +from sqlalchemy.orm import relationship, Mapped, mapped_column +from datetime import datetime +from watchapedia.database.common import Base +from sqlalchemy import DateTime +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from watchapedia.app.movie.models import Movie + from watchapedia.app.user.models import User + +class MovieCollection(Base): + __tablename__ = 'movie_collection' + + movie_id: Mapped[int] = mapped_column(Integer, ForeignKey("movie.id"), nullable=False, primary_key=True) + collection_id: Mapped[int] = mapped_column(Integer, ForeignKey("collection.id"), nullable=False, primary_key=True) + +class CollectionComment(Base): + __tablename__ = 'collection_comment' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + content: Mapped[str] = mapped_column(String(500), nullable=False) + likes_count: Mapped[int] = mapped_column(Integer, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), nullable=False + ) + user: Mapped["User"] = relationship("User", back_populates="collection_comments") + + collection_id: Mapped[int] = mapped_column( + Integer, ForeignKey("collection.id"), nullable=False + ) + collection: Mapped["Collection"] = relationship("Collection", back_populates="comments") + +class Collection(Base): + __tablename__ = 'collection' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + title: Mapped[str] = mapped_column(String(100), nullable=False) + overview: Mapped[str | None] = mapped_column(String(500)) + likes_count: Mapped[int] = mapped_column(Integer, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), nullable=False + ) + user: Mapped["User"] = relationship("User", back_populates="collections") + + movies: Mapped[list["Movie"]] = relationship(secondary="movie_collection", back_populates="collections") + comments: Mapped[list["CollectionComment"]] = relationship("CollectionComment", back_populates="collection") + +class UserLikesCollectionComment(Base): + __tablename__ = 'user_likes_collection_comment' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), nullable=False + ) + collection_comment_id: Mapped[int] = mapped_column( + Integer, ForeignKey("collection_comment.id"), nullable=False + ) diff --git a/watchapedia/app/movie/models.py b/watchapedia/app/movie/models.py index 5553c32..4c0c0c2 100644 --- a/watchapedia/app/movie/models.py +++ b/watchapedia/app/movie/models.py @@ -8,6 +8,7 @@ from watchapedia.app.genre.models import Genre from watchapedia.app.country.models import Country from watchapedia.app.participant.models import Participant + from watchapedia.app.collection.models import Collection class MovieParticipant(Base): __tablename__ = "movie_participant" @@ -48,6 +49,7 @@ class Movie(Base): genres: Mapped[list["Genre"]] = relationship(secondary="movie_genre", back_populates="movies") countries: Mapped[list["Country"]] = relationship(secondary="movie_country", back_populates="movies") + collections: Mapped[list["Collection"]] = relationship(secondary="movie_collection", back_populates="movies") movie_participants: Mapped[list[MovieParticipant]] = relationship("MovieParticipant", back_populates="movie") diff --git a/watchapedia/app/user/models.py b/watchapedia/app/user/models.py index 302b2d9..fd838bf 100644 --- a/watchapedia/app/user/models.py +++ b/watchapedia/app/user/models.py @@ -7,6 +7,7 @@ if TYPE_CHECKING: from watchapedia.app.review.models import Review from watchapedia.app.comment.models import Comment + from watchapedia.app.collection.models import Collection, CollectionComment class User(Base): __tablename__ = 'user' @@ -18,6 +19,8 @@ class User(Base): reviews: Mapped[list["Review"]] = relationship("Review", back_populates="user") comments: Mapped[list["Comment"]] = relationship("Comment", back_populates="user") + collections: Mapped[list["Collection"]] = relationship("Collection", back_populates="user") + collection_comments: Mapped[list["CollectionComment"]] = relationship("CollectionComment", back_populates="user") class BlockedToken(Base): __tablename__ = "blocked_token" diff --git a/watchapedia/database/__init__.py b/watchapedia/database/__init__.py index a3af340..6e457dd 100644 --- a/watchapedia/database/__init__.py +++ b/watchapedia/database/__init__.py @@ -4,4 +4,5 @@ import watchapedia.app.country.models import watchapedia.app.participant.models import watchapedia.app.review.models -import watchapedia.app.comment.models \ No newline at end of file +import watchapedia.app.comment.models +import watchapedia.app.collection.models \ No newline at end of file diff --git a/watchapedia/settings.py b/watchapedia/settings.py index de07509..138382a 100644 --- a/watchapedia/settings.py +++ b/watchapedia/settings.py @@ -2,7 +2,7 @@ from pydantic_settings import BaseSettings -ENV = os.getenv("ENV", "prod") # 배포 시에는(merge) prod로 바꿔주세요 +ENV = os.getenv("ENV", "local") # 배포 시에는(merge) prod로 바꿔주세요 assert ENV in ("local", "prod") From 93a6ca5a13891c11f2dfc084d32895e9f5aa329c Mon Sep 17 00:00:00 2001 From: anandashin Date: Tue, 14 Jan 2025 14:47:24 +0900 Subject: [PATCH 02/26] alembic versions file update --- watchapedia/app/collection/dto/requests.py | 0 ..._01_14_1446-e9b6097534f2_collection_etc.py | 68 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 watchapedia/app/collection/dto/requests.py create mode 100644 watchapedia/database/alembic/versions/2025_01_14_1446-e9b6097534f2_collection_etc.py diff --git a/watchapedia/app/collection/dto/requests.py b/watchapedia/app/collection/dto/requests.py new file mode 100644 index 0000000..e69de29 diff --git a/watchapedia/database/alembic/versions/2025_01_14_1446-e9b6097534f2_collection_etc.py b/watchapedia/database/alembic/versions/2025_01_14_1446-e9b6097534f2_collection_etc.py new file mode 100644 index 0000000..3ddb593 --- /dev/null +++ b/watchapedia/database/alembic/versions/2025_01_14_1446-e9b6097534f2_collection_etc.py @@ -0,0 +1,68 @@ +"""collection etc + +Revision ID: e9b6097534f2 +Revises: 3c1bdf2a174a +Create Date: 2025-01-14 14:46:19.567806 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e9b6097534f2' +down_revision: Union[str, None] = '3c1bdf2a174a' +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! ### + op.create_table('collection', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=100), nullable=False), + sa.Column('overview', sa.String(length=500), nullable=True), + sa.Column('likes_count', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('collection_comment', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('content', sa.String(length=500), nullable=False), + sa.Column('likes_count', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('collection_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['collection_id'], ['collection.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('movie_collection', + sa.Column('movie_id', sa.Integer(), nullable=False), + sa.Column('collection_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['collection_id'], ['collection.id'], ), + sa.ForeignKeyConstraint(['movie_id'], ['movie.id'], ), + sa.PrimaryKeyConstraint('movie_id', 'collection_id') + ) + op.create_table('user_likes_collection_comment', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('collection_comment_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['collection_comment_id'], ['collection_comment.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('user_likes_collection_comment') + op.drop_table('movie_collection') + op.drop_table('collection_comment') + op.drop_table('collection') + # ### end Alembic commands ### From 11173c7429f003d14f5a39a2e0a68eef07ce7e5e Mon Sep 17 00:00:00 2001 From: anandashin Date: Tue, 14 Jan 2025 14:58:12 +0900 Subject: [PATCH 03/26] =?UTF-8?q?feat:=20requests.py=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/collection/dto/requests.py | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/watchapedia/app/collection/dto/requests.py b/watchapedia/app/collection/dto/requests.py index e69de29..faff711 100644 --- a/watchapedia/app/collection/dto/requests.py +++ b/watchapedia/app/collection/dto/requests.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel +from watchapedia.common.errors import InvalidFieldFormatError +from typing import Annotated +from pydantic.functional_validators import AfterValidator + +def validate_title(value: str | None) -> str | None: + # title는 100자 이내여야 함 + if value is None: + return value + if len(value) > 100: + raise InvalidFieldFormatError("title") + return value + +def validate_overview(value: str | None) -> str | None: + # overview는 500자 이내여야 함 + if value is None: + return value + if len(value) > 500: + raise InvalidFieldFormatError("overview") + return value + + +class CollectionCreateRequest(BaseModel): + title: Annotated[str, AfterValidator(validate_title)] + overview: Annotated[str | None, AfterValidator(validate_overview)] = None + movie_id: list[int] | None = None + +class CollectionUpdateRequest(BaseModel): + title: Annotated[str | None, AfterValidator(validate_title)] = None + overview: Annotated[str | None, AfterValidator(validate_overview)] = None + movie_id: list[int] | None = None From bf49e1206494aebe407cc91aa4c92ad854243807 Mon Sep 17 00:00:00 2001 From: anandashin Date: Tue, 14 Jan 2025 15:10:38 +0900 Subject: [PATCH 04/26] =?UTF-8?q?fix:=20collection=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=88=98=EC=A0=95=20-=20ondelete,=20casca?= =?UTF-8?q?de?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/collection/models.py | 26 ++++++--- watchapedia/app/user/models.py | 8 ++- ..._01_14_1509-65dc72edb8c5_fix_collection.py | 56 +++++++++++++++++++ 3 files changed, 79 insertions(+), 11 deletions(-) create mode 100644 watchapedia/database/alembic/versions/2025_01_14_1509-65dc72edb8c5_fix_collection.py diff --git a/watchapedia/app/collection/models.py b/watchapedia/app/collection/models.py index 1d48d81..1d33864 100644 --- a/watchapedia/app/collection/models.py +++ b/watchapedia/app/collection/models.py @@ -11,8 +11,12 @@ class MovieCollection(Base): __tablename__ = 'movie_collection' - movie_id: Mapped[int] = mapped_column(Integer, ForeignKey("movie.id"), nullable=False, primary_key=True) - collection_id: Mapped[int] = mapped_column(Integer, ForeignKey("collection.id"), nullable=False, primary_key=True) + movie_id: Mapped[int] = mapped_column( + Integer, ForeignKey("movie.id", ondelete="CASCADE"), nullable=False, primary_key=True + ) + collection_id: Mapped[int] = mapped_column( + Integer, ForeignKey("collection.id", ondelete="CASCADE"), nullable=False, primary_key=True + ) class CollectionComment(Base): __tablename__ = 'collection_comment' @@ -23,12 +27,12 @@ class CollectionComment(Base): created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("user.id"), nullable=False + Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) user: Mapped["User"] = relationship("User", back_populates="collection_comments") collection_id: Mapped[int] = mapped_column( - Integer, ForeignKey("collection.id"), nullable=False + Integer, ForeignKey("collection.id", ondelete="CASCADE"), nullable=False ) collection: Mapped["Collection"] = relationship("Collection", back_populates="comments") @@ -42,20 +46,24 @@ class Collection(Base): created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("user.id"), nullable=False + Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) user: Mapped["User"] = relationship("User", back_populates="collections") - movies: Mapped[list["Movie"]] = relationship(secondary="movie_collection", back_populates="collections") - comments: Mapped[list["CollectionComment"]] = relationship("CollectionComment", back_populates="collection") + movies: Mapped[list["Movie"]] = relationship( + secondary="movie_collection", back_populates="collections", cascade="all, delete" + ) + comments: Mapped[list["CollectionComment"]] = relationship( + "CollectionComment", back_populates="collection", cascade="all, delete, delete-orphan" + ) class UserLikesCollectionComment(Base): __tablename__ = 'user_likes_collection_comment' id: Mapped[int] = mapped_column(Integer, primary_key=True) user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("user.id"), nullable=False + Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) collection_comment_id: Mapped[int] = mapped_column( - Integer, ForeignKey("collection_comment.id"), nullable=False + Integer, ForeignKey("collection_comment.id", ondelete="CASCADE"), nullable=False ) diff --git a/watchapedia/app/user/models.py b/watchapedia/app/user/models.py index fd838bf..0232237 100644 --- a/watchapedia/app/user/models.py +++ b/watchapedia/app/user/models.py @@ -19,8 +19,12 @@ class User(Base): reviews: Mapped[list["Review"]] = relationship("Review", back_populates="user") comments: Mapped[list["Comment"]] = relationship("Comment", back_populates="user") - collections: Mapped[list["Collection"]] = relationship("Collection", back_populates="user") - collection_comments: Mapped[list["CollectionComment"]] = relationship("CollectionComment", back_populates="user") + collections: Mapped[list["Collection"]] = relationship( + "Collection", back_populates="user", cascade="all, delete, delete-orphan" + ) + collection_comments: Mapped[list["CollectionComment"]] = relationship( + "CollectionComment", back_populates="user", cascade="all, delete, delete-orphan" + ) class BlockedToken(Base): __tablename__ = "blocked_token" diff --git a/watchapedia/database/alembic/versions/2025_01_14_1509-65dc72edb8c5_fix_collection.py b/watchapedia/database/alembic/versions/2025_01_14_1509-65dc72edb8c5_fix_collection.py new file mode 100644 index 0000000..61a617b --- /dev/null +++ b/watchapedia/database/alembic/versions/2025_01_14_1509-65dc72edb8c5_fix_collection.py @@ -0,0 +1,56 @@ +"""fix collection + +Revision ID: 65dc72edb8c5 +Revises: e9b6097534f2 +Create Date: 2025-01-14 15:09:19.113423 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '65dc72edb8c5' +down_revision: Union[str, None] = 'e9b6097534f2' +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! ### + op.drop_constraint('collection_ibfk_1', 'collection', type_='foreignkey') + op.create_foreign_key(None, 'collection', 'user', ['user_id'], ['id'], ondelete='CASCADE') + op.drop_constraint('collection_comment_ibfk_2', 'collection_comment', type_='foreignkey') + op.drop_constraint('collection_comment_ibfk_1', 'collection_comment', type_='foreignkey') + op.create_foreign_key(None, 'collection_comment', 'collection', ['collection_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key(None, 'collection_comment', 'user', ['user_id'], ['id'], ondelete='CASCADE') + op.drop_constraint('movie_collection_ibfk_2', 'movie_collection', type_='foreignkey') + op.drop_constraint('movie_collection_ibfk_1', 'movie_collection', type_='foreignkey') + op.create_foreign_key(None, 'movie_collection', 'collection', ['collection_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key(None, 'movie_collection', 'movie', ['movie_id'], ['id'], ondelete='CASCADE') + op.drop_constraint('user_likes_collection_comment_ibfk_1', 'user_likes_collection_comment', type_='foreignkey') + op.drop_constraint('user_likes_collection_comment_ibfk_2', 'user_likes_collection_comment', type_='foreignkey') + op.create_foreign_key(None, 'user_likes_collection_comment', 'collection_comment', ['collection_comment_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key(None, 'user_likes_collection_comment', 'user', ['user_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'user_likes_collection_comment', type_='foreignkey') + op.drop_constraint(None, 'user_likes_collection_comment', type_='foreignkey') + op.create_foreign_key('user_likes_collection_comment_ibfk_2', 'user_likes_collection_comment', 'user', ['user_id'], ['id']) + op.create_foreign_key('user_likes_collection_comment_ibfk_1', 'user_likes_collection_comment', 'collection_comment', ['collection_comment_id'], ['id']) + op.drop_constraint(None, 'movie_collection', type_='foreignkey') + op.drop_constraint(None, 'movie_collection', type_='foreignkey') + op.create_foreign_key('movie_collection_ibfk_1', 'movie_collection', 'collection', ['collection_id'], ['id']) + op.create_foreign_key('movie_collection_ibfk_2', 'movie_collection', 'movie', ['movie_id'], ['id']) + op.drop_constraint(None, 'collection_comment', type_='foreignkey') + op.drop_constraint(None, 'collection_comment', type_='foreignkey') + op.create_foreign_key('collection_comment_ibfk_1', 'collection_comment', 'collection', ['collection_id'], ['id']) + op.create_foreign_key('collection_comment_ibfk_2', 'collection_comment', 'user', ['user_id'], ['id']) + op.drop_constraint(None, 'collection', type_='foreignkey') + op.create_foreign_key('collection_ibfk_1', 'collection', 'user', ['user_id'], ['id']) + # ### end Alembic commands ### From 6e32457e2e729c11e03c720ad27630079328ce95 Mon Sep 17 00:00:00 2001 From: anandashin Date: Tue, 14 Jan 2025 16:31:34 +0900 Subject: [PATCH 05/26] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/api.py | 4 +- watchapedia/app/collection/dto/requests.py | 4 +- watchapedia/app/collection/dto/responses.py | 18 +++++++ watchapedia/app/collection/errors.py | 5 ++ watchapedia/app/collection/repository.py | 36 ++++++++++++++ watchapedia/app/collection/service.py | 54 +++++++++++++++++++++ watchapedia/app/collection/views.py | 25 ++++++++++ 7 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 watchapedia/app/collection/dto/responses.py create mode 100644 watchapedia/app/collection/errors.py create mode 100644 watchapedia/app/collection/repository.py create mode 100644 watchapedia/app/collection/service.py create mode 100644 watchapedia/app/collection/views.py diff --git a/watchapedia/api.py b/watchapedia/api.py index 315edea..fc3d7dc 100644 --- a/watchapedia/api.py +++ b/watchapedia/api.py @@ -4,6 +4,7 @@ from watchapedia.app.review.views import review_router from watchapedia.app.comment.views import comment_router from watchapedia.app.participant.views import participant_router +from watchapedia.app.collection.views import collection_router api_router = APIRouter() @@ -11,4 +12,5 @@ api_router.include_router(movie_router, prefix='/movies', tags=['movies']) api_router.include_router(review_router, prefix='/reviews', tags=['reviews']) api_router.include_router(comment_router, prefix='/comments', tags=['comments']) -api_router.include_router(participant_router, prefix='/participants', tags=['participants']) \ No newline at end of file +api_router.include_router(participant_router, prefix='/participants', tags=['participants']) +api_router.include_router(collection_router, prefix='/collections', tags=['collections']) \ No newline at end of file diff --git a/watchapedia/app/collection/dto/requests.py b/watchapedia/app/collection/dto/requests.py index faff711..20d5e65 100644 --- a/watchapedia/app/collection/dto/requests.py +++ b/watchapedia/app/collection/dto/requests.py @@ -23,9 +23,9 @@ def validate_overview(value: str | None) -> str | None: class CollectionCreateRequest(BaseModel): title: Annotated[str, AfterValidator(validate_title)] overview: Annotated[str | None, AfterValidator(validate_overview)] = None - movie_id: list[int] | None = None + movie_ids: list[int] | None = None class CollectionUpdateRequest(BaseModel): title: Annotated[str | None, AfterValidator(validate_title)] = None overview: Annotated[str | None, AfterValidator(validate_overview)] = None - movie_id: list[int] | None = None + movie_ids: list[int] | None = None diff --git a/watchapedia/app/collection/dto/responses.py b/watchapedia/app/collection/dto/responses.py new file mode 100644 index 0000000..833d136 --- /dev/null +++ b/watchapedia/app/collection/dto/responses.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel +from datetime import datetime + +class MovieCompactResponse(BaseModel): + id: int + title: str + poster_url: str | None + average_rating: float | None + +class CollectionResponse(BaseModel): + id: int + user_id: int + title: str + overview: str | None + likes_count: int + comments_count: int + created_at: datetime + movies: list[MovieCompactResponse] \ No newline at end of file diff --git a/watchapedia/app/collection/errors.py b/watchapedia/app/collection/errors.py new file mode 100644 index 0000000..83dfddf --- /dev/null +++ b/watchapedia/app/collection/errors.py @@ -0,0 +1,5 @@ +from fastapi import HTTPException + +class CollectionNotFoundError(HTTPException): + def __init__(self): + super().__init__(status_code=404, detail="Collection not found") \ No newline at end of file diff --git a/watchapedia/app/collection/repository.py b/watchapedia/app/collection/repository.py new file mode 100644 index 0000000..3a4ff8c --- /dev/null +++ b/watchapedia/app/collection/repository.py @@ -0,0 +1,36 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session +from fastapi import Depends +from watchapedia.database.connection import get_db_session +from typing import Annotated, Sequence +from watchapedia.app.movie.models import Movie +from watchapedia.app.collection.models import Collection, UserLikesCollectionComment +from watchapedia.app.collection.errors import CollectionNotFoundError +from datetime import datetime + +class CollectionRepository(): + def __init__(self, session: Annotated[Session, Depends(get_db_session)]) -> None: + self.session = session + + def create_collection( + self, user_id: int, title: str, overview: str | None, created_at: datetime + ) -> Collection: + collection = Collection( + user_id=user_id, + title=title, + overview=overview, + likes_count=0, + created_at=created_at + ) + self.session.add(collection) + self.session.flush() + + return collection + + def add_collection_movie( + self, collection: Collection, movies: list[Movie] + ) -> None: + for movie in movies: + collection.movies.append(movie) + + self.session.flush() diff --git a/watchapedia/app/collection/service.py b/watchapedia/app/collection/service.py new file mode 100644 index 0000000..93b727d --- /dev/null +++ b/watchapedia/app/collection/service.py @@ -0,0 +1,54 @@ +from typing import Annotated +from fastapi import Depends +from datetime import datetime +from watchapedia.app.collection.repository import CollectionRepository +from watchapedia.app.collection.models import Collection +from watchapedia.app.collection.dto.responses import CollectionResponse, MovieCompactResponse +from watchapedia.app.movie.repository import MovieRepository +from watchapedia.app.movie.errors import MovieNotFoundError + +class CollectionService: + def __init__( + self, + movie_repository: Annotated[MovieRepository, Depends()], + collection_repository: Annotated[CollectionRepository, Depends()] + ) -> None: + self.movie_repository = movie_repository + self.collection_repository = collection_repository + + def create_collection( + self, user_id: int, movie_ids: list[int] | None, title: str, overview: str | None + ) -> CollectionResponse: + collection = self.collection_repository.create_collection( + user_id=user_id, title=title, overview=overview, created_at=datetime.now() + ) + if movie_ids: + movies = [] + for movie_id in movie_ids: + movie = self.movie_repository.get_movie_by_movie_id(movie_id=movie_id) + if movie is None: + raise MovieNotFoundError() + movies.append(movie) + self.collection_repository.add_collection_movie(collection=collection, movies=movies) + + return self._process_collection_process(collection) + + def _process_collection_process(self, collection: Collection) -> CollectionResponse: + return CollectionResponse( + id=collection.id, + user_id=collection.user_id, + title=collection.title, + overview=collection.overview, + likes_count=collection.likes_count, + comments_count=len(collection.comments), + created_at=collection.created_at, + movies=[ + MovieCompactResponse( + id=movie.id, + title=movie.title, + poster_url=movie.poster_url, + average_rating=movie.average_rating + ) + for movie in collection.movies + ] + ) \ No newline at end of file diff --git a/watchapedia/app/collection/views.py b/watchapedia/app/collection/views.py new file mode 100644 index 0000000..d383bf6 --- /dev/null +++ b/watchapedia/app/collection/views.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse +from typing import Annotated +from datetime import datetime +from watchapedia.app.user.views import login_with_header +from watchapedia.app.user.models import User +from watchapedia.app.collection.service import CollectionService +from watchapedia.app.collection.dto.requests import CollectionCreateRequest +from watchapedia.app.collection.dto.responses import CollectionResponse + +collection_router = APIRouter() + +@collection_router.post("", + status_code=201, + summary="컬렉션 생성", + description="[로그인 필요] 컬렉션 title, overview(소개글), movie_ids를 받아 컬랙션을 생성하고 성공 시 컬렉션을 반환합니다." + ) +def create_collection( + user: Annotated[User, Depends(login_with_header)], + collection_service: Annotated[CollectionService, Depends()], + collection_request: CollectionCreateRequest, +) -> CollectionResponse: + return collection_service.create_collection( + user.id, collection_request.movie_ids, collection_request.title, collection_request.overview + ) \ No newline at end of file From 4a28b85dd7a87c2121502719490cc9dfeb2014e1 Mon Sep 17 00:00:00 2001 From: anandashin Date: Tue, 14 Jan 2025 16:52:45 +0900 Subject: [PATCH 06/26] =?UTF-8?q?feat:=20search=5Fcollection=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/collection/repository.py | 4 ++++ watchapedia/app/collection/service.py | 7 +++++++ watchapedia/app/collection/views.py | 12 +++++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/watchapedia/app/collection/repository.py b/watchapedia/app/collection/repository.py index 3a4ff8c..7d0a955 100644 --- a/watchapedia/app/collection/repository.py +++ b/watchapedia/app/collection/repository.py @@ -34,3 +34,7 @@ def add_collection_movie( collection.movies.append(movie) self.session.flush() + + def get_collection_by_collection_id(self, collection_id: int) -> Collection | None: + get_collection_query = select(Collection).filter(Collection.id == collection_id) + return self.session.scalar(get_collection_query) diff --git a/watchapedia/app/collection/service.py b/watchapedia/app/collection/service.py index 93b727d..cce5f2c 100644 --- a/watchapedia/app/collection/service.py +++ b/watchapedia/app/collection/service.py @@ -6,6 +6,7 @@ from watchapedia.app.collection.dto.responses import CollectionResponse, MovieCompactResponse from watchapedia.app.movie.repository import MovieRepository from watchapedia.app.movie.errors import MovieNotFoundError +from watchapedia.app.collection.errors import CollectionNotFoundError class CollectionService: def __init__( @@ -33,6 +34,12 @@ def create_collection( return self._process_collection_process(collection) + def get_collection_by_collection_id(self, collection_id: int) -> CollectionResponse: + collection = self.collection_repository.get_collection_by_collection_id(collection_id=collection_id) + if collection is None: + raise CollectionNotFoundError() + return self._process_collection_process(collection) + def _process_collection_process(self, collection: Collection) -> CollectionResponse: return CollectionResponse( id=collection.id, diff --git a/watchapedia/app/collection/views.py b/watchapedia/app/collection/views.py index d383bf6..01bb803 100644 --- a/watchapedia/app/collection/views.py +++ b/watchapedia/app/collection/views.py @@ -22,4 +22,14 @@ def create_collection( ) -> CollectionResponse: return collection_service.create_collection( user.id, collection_request.movie_ids, collection_request.title, collection_request.overview - ) \ No newline at end of file + ) + +@collection_router.get("/{collection_id}", + status_code=200, + summary="컬렉션 조회", + description="컬렉션 id로 조회하여 성공 시 컬렉션을 반환합니다.") +def search_collection( + collection_id: int, + collection_service: Annotated[CollectionService, Depends()], +) -> CollectionResponse: + return collection_service.get_collection_by_collection_id(collection_id) \ No newline at end of file From a07ac197492b85a205d585b59709eeed7c0852b6 Mon Sep 17 00:00:00 2001 From: anandashin Date: Tue, 14 Jan 2025 18:08:06 +0900 Subject: [PATCH 07/26] =?UTF-8?q?feat:=20update=20collection=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/collection/dto/requests.py | 3 +- watchapedia/app/collection/errors.py | 15 +++++++++- watchapedia/app/collection/repository.py | 25 ++++++++++++++++ watchapedia/app/collection/service.py | 34 +++++++++++++++++++++- watchapedia/app/collection/views.py | 23 +++++++++++++-- 5 files changed, 94 insertions(+), 6 deletions(-) diff --git a/watchapedia/app/collection/dto/requests.py b/watchapedia/app/collection/dto/requests.py index 20d5e65..20b60a4 100644 --- a/watchapedia/app/collection/dto/requests.py +++ b/watchapedia/app/collection/dto/requests.py @@ -28,4 +28,5 @@ class CollectionCreateRequest(BaseModel): class CollectionUpdateRequest(BaseModel): title: Annotated[str | None, AfterValidator(validate_title)] = None overview: Annotated[str | None, AfterValidator(validate_overview)] = None - movie_ids: list[int] | None = None + add_movie_ids: list[int] | None = None + delete_movie_ids: list[int] | None = None diff --git a/watchapedia/app/collection/errors.py b/watchapedia/app/collection/errors.py index 83dfddf..498ca58 100644 --- a/watchapedia/app/collection/errors.py +++ b/watchapedia/app/collection/errors.py @@ -2,4 +2,17 @@ class CollectionNotFoundError(HTTPException): def __init__(self): - super().__init__(status_code=404, detail="Collection not found") \ No newline at end of file + super().__init__(status_code=404, detail="Collection not found") + +class InvalidFormatError(HTTPException): + def __init__(self): + super().__init__(status_code=400, detail="Collection Invalid Format") + +class NoSuchMovieError(HTTPException): + def __init__(self): + super().__init__(status_code=400, detail="No Such Movie in the Collection") + +class MovieAlreadyExistsError(HTTPException): + def __init__(self): + super().__init__(status_code=409, detail="Movie already exists in the Collection") + diff --git a/watchapedia/app/collection/repository.py b/watchapedia/app/collection/repository.py index 7d0a955..4f83f8d 100644 --- a/watchapedia/app/collection/repository.py +++ b/watchapedia/app/collection/repository.py @@ -35,6 +35,31 @@ def add_collection_movie( self.session.flush() + def update_collection( + self, collection: Collection, title: str | None, overview: str | None, add_movie_ids: list[int] | None, delete_movie_ids: list[int] | None + ) -> None: + if title: + collection.title = title + + if overview: + collection.overview = overview + + if delete_movie_ids: + for delete_movie_id in delete_movie_ids: + delete_movie = self.get_movie_by_movie_id(delete_movie_id) + collection.movies.remove(delete_movie) + + if add_movie_ids: + for add_movie_id in add_movie_ids: + add_movie = self.get_movie_by_movie_id(add_movie_id) + collection.movies.append(add_movie) + + self.session.flush() + def get_collection_by_collection_id(self, collection_id: int) -> Collection | None: get_collection_query = select(Collection).filter(Collection.id == collection_id) return self.session.scalar(get_collection_query) + + def get_movie_by_movie_id(self, movie_id: int) -> Movie | None: + get_movie_query = select(Movie).filter(Movie.id==movie_id) + return self.session.scalar(get_movie_query) diff --git a/watchapedia/app/collection/service.py b/watchapedia/app/collection/service.py index cce5f2c..45cf75a 100644 --- a/watchapedia/app/collection/service.py +++ b/watchapedia/app/collection/service.py @@ -6,7 +6,8 @@ from watchapedia.app.collection.dto.responses import CollectionResponse, MovieCompactResponse from watchapedia.app.movie.repository import MovieRepository from watchapedia.app.movie.errors import MovieNotFoundError -from watchapedia.app.collection.errors import CollectionNotFoundError +from watchapedia.app.collection.errors import * +from watchapedia.common.errors import PermissionDeniedError class CollectionService: def __init__( @@ -34,12 +35,43 @@ def create_collection( return self._process_collection_process(collection) + def update_collection( + self, collection_id: int, user_id: int, title: int | None, overview: int | None, add_movie_ids: list[int] | None, delete_movie_ids: list[int] | None + ) -> None: + if not any([title, overview, add_movie_ids, delete_movie_ids]): + raise InvalidFormatError() + collection = self.collection_repository.get_collection_by_collection_id(collection_id) + if not collection.user_id == user_id: + raise PermissionDeniedError() + + movie_id_list = self.get_movie_ids_from_collection(collection_id) + if delete_movie_ids: + for delete_movie_id in delete_movie_ids: + if delete_movie_id not in movie_id_list: + raise NoSuchMovieError() + if add_movie_ids: + for add_movie_id in add_movie_ids: + if self.movie_repository.get_movie_by_movie_id(add_movie_id) is None: + raise MovieNotFoundError() + if add_movie_id in movie_id_list: + raise MovieAlreadyExistsError() + + self.collection_repository.update_collection( + collection=collection, title=title, overview=overview, add_movie_ids=add_movie_ids, delete_movie_ids=delete_movie_ids + ) + def get_collection_by_collection_id(self, collection_id: int) -> CollectionResponse: collection = self.collection_repository.get_collection_by_collection_id(collection_id=collection_id) if collection is None: raise CollectionNotFoundError() return self._process_collection_process(collection) + def get_movie_ids_from_collection(self, collection_id: int) -> list[int] | None: + collection = self.collection_repository.get_collection_by_collection_id(collection_id=collection_id) + if collection is None: + raise CollectionNotFoundError() + return [ movie.id for movie in collection.movies ] + def _process_collection_process(self, collection: Collection) -> CollectionResponse: return CollectionResponse( id=collection.id, diff --git a/watchapedia/app/collection/views.py b/watchapedia/app/collection/views.py index 01bb803..e29496e 100644 --- a/watchapedia/app/collection/views.py +++ b/watchapedia/app/collection/views.py @@ -5,7 +5,7 @@ from watchapedia.app.user.views import login_with_header from watchapedia.app.user.models import User from watchapedia.app.collection.service import CollectionService -from watchapedia.app.collection.dto.requests import CollectionCreateRequest +from watchapedia.app.collection.dto.requests import CollectionCreateRequest, CollectionUpdateRequest from watchapedia.app.collection.dto.responses import CollectionResponse collection_router = APIRouter() @@ -27,9 +27,26 @@ def create_collection( @collection_router.get("/{collection_id}", status_code=200, summary="컬렉션 조회", - description="컬렉션 id로 조회하여 성공 시 컬렉션을 반환합니다.") + description="컬렉션 id로 조회하여 성공 시 컬렉션을 반환합니다." + ) def search_collection( collection_id: int, collection_service: Annotated[CollectionService, Depends()], ) -> CollectionResponse: - return collection_service.get_collection_by_collection_id(collection_id) \ No newline at end of file + return collection_service.get_collection_by_collection_id(collection_id) + +@collection_router.patch("/{collection_id}", + status_code=200, + summary="컬렉션 업데이트", + description="[로그인 필요] title, overview(소개글), 추가할 영화 id 목록, 삭제할 영화 id 목록을 받아 성공 시 'Success'을 반환합니다." + ) +def update_collection( + user: Annotated[User, Depends(login_with_header)], + collection_id: int, + collection_request: CollectionUpdateRequest, + collection_service: Annotated[CollectionService, Depends()] +): + collection_service.update_collection( + collection_id, user.id, collection_request.title, collection_request.overview, collection_request.add_movie_ids, collection_request.delete_movie_ids + ) + return "Success" \ No newline at end of file From 30ec674b0baf87b407b60754a3b5623d24a7ecd4 Mon Sep 17 00:00:00 2001 From: anandashin Date: Tue, 14 Jan 2025 20:48:25 +0900 Subject: [PATCH 08/26] =?UTF-8?q?feat:=20user=5Flikes=5Fcollection=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80,=20like=5Fcollection=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/collection/models.py | 11 ++++++ watchapedia/app/collection/repository.py | 22 ++++++++++- watchapedia/app/collection/service.py | 5 +++ watchapedia/app/collection/views.py | 14 ++++++- ...35-ac577377af66_add_userlikescollection.py | 37 +++++++++++++++++++ 5 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 watchapedia/database/alembic/versions/2025_01_14_2035-ac577377af66_add_userlikescollection.py diff --git a/watchapedia/app/collection/models.py b/watchapedia/app/collection/models.py index 1d33864..e3e6435 100644 --- a/watchapedia/app/collection/models.py +++ b/watchapedia/app/collection/models.py @@ -57,6 +57,17 @@ class Collection(Base): "CollectionComment", back_populates="collection", cascade="all, delete, delete-orphan" ) +class UserLikesCollection(Base): + __tablename__ = 'user_likes_collection' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False + ) + collection_id: Mapped[int] = mapped_column( + Integer, ForeignKey("collection.id", ondelete="CASCADE"), nullable=False + ) + class UserLikesCollectionComment(Base): __tablename__ = 'user_likes_collection_comment' diff --git a/watchapedia/app/collection/repository.py b/watchapedia/app/collection/repository.py index 4f83f8d..8deb36c 100644 --- a/watchapedia/app/collection/repository.py +++ b/watchapedia/app/collection/repository.py @@ -4,7 +4,7 @@ from watchapedia.database.connection import get_db_session from typing import Annotated, Sequence from watchapedia.app.movie.models import Movie -from watchapedia.app.collection.models import Collection, UserLikesCollectionComment +from watchapedia.app.collection.models import Collection, UserLikesCollection, UserLikesCollectionComment from watchapedia.app.collection.errors import CollectionNotFoundError from datetime import datetime @@ -56,6 +56,26 @@ def update_collection( self.session.flush() + def like_collection(self, user_id: int, collection: Collection) -> Collection: + get_like_query = select(UserLikesCollection).filter( + (UserLikesCollection.user_id == user_id) + & (UserLikesCollection.collection_id == collection.id) + ) + user_likes_collection = self.session.scalar(get_like_query) + + if user_likes_collection is None: + user_likes_collection = UserLikesCollection( + user_id=user_id, + collection_id=collection.id + ) + self.session.add(user_likes_collection) + collection.likes_count += 1 + else: + self.session.delete(user_likes_collection) + collection.likes_count -= 1 + self.session.flush() + return collection + def get_collection_by_collection_id(self, collection_id: int) -> Collection | None: get_collection_query = select(Collection).filter(Collection.id == collection_id) return self.session.scalar(get_collection_query) diff --git a/watchapedia/app/collection/service.py b/watchapedia/app/collection/service.py index 45cf75a..f3740e8 100644 --- a/watchapedia/app/collection/service.py +++ b/watchapedia/app/collection/service.py @@ -72,6 +72,11 @@ def get_movie_ids_from_collection(self, collection_id: int) -> list[int] | None: raise CollectionNotFoundError() return [ movie.id for movie in collection.movies ] + def like_collection(self, user_id: int, collection_id: int) -> CollectionResponse: + collection = self.collection_repository.get_collection_by_collection_id(collection_id) + updated_collection = self.collection_repository.like_collection(user_id, collection) + return self._process_collection_process(updated_collection) + def _process_collection_process(self, collection: Collection) -> CollectionResponse: return CollectionResponse( id=collection.id, diff --git a/watchapedia/app/collection/views.py b/watchapedia/app/collection/views.py index e29496e..55aaf08 100644 --- a/watchapedia/app/collection/views.py +++ b/watchapedia/app/collection/views.py @@ -49,4 +49,16 @@ def update_collection( collection_service.update_collection( collection_id, user.id, collection_request.title, collection_request.overview, collection_request.add_movie_ids, collection_request.delete_movie_ids ) - return "Success" \ No newline at end of file + return "Success" + +@collection_router.patch('/like/{collection_id}', + status_code=200, + summary="컬렉션 추천/취소", + description="[로그인 필요] collection_id를 받아 추천되어 있지 않으면 추천하고, 추천되어 있으면 취소합니다.", + ) +def like_collection( + user: Annotated[User, Depends(login_with_header)], + collection_id: int, + collection_service: Annotated[CollectionService, Depends()], +) -> CollectionResponse: + return collection_service.like_collection(user.id, collection_id) \ No newline at end of file diff --git a/watchapedia/database/alembic/versions/2025_01_14_2035-ac577377af66_add_userlikescollection.py b/watchapedia/database/alembic/versions/2025_01_14_2035-ac577377af66_add_userlikescollection.py new file mode 100644 index 0000000..cf8a2ae --- /dev/null +++ b/watchapedia/database/alembic/versions/2025_01_14_2035-ac577377af66_add_userlikescollection.py @@ -0,0 +1,37 @@ +"""add UserLikesCollection + +Revision ID: ac577377af66 +Revises: 65dc72edb8c5 +Create Date: 2025-01-14 20:35:57.156638 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ac577377af66' +down_revision: Union[str, None] = '65dc72edb8c5' +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! ### + op.create_table('user_likes_collection', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('collection_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['collection_id'], ['collection.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('user_likes_collection') + # ### end Alembic commands ### From 797f04de06d981fb7dbceeb1f6ba3ea032ff44be Mon Sep 17 00:00:00 2001 From: anandashin Date: Tue, 14 Jan 2025 21:12:08 +0900 Subject: [PATCH 09/26] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EC=BB=AC?= =?UTF-8?q?=EB=A0=89=EC=85=98=20=EC=B6=9C=EB=A0=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/collection/repository.py | 4 ++++ watchapedia/app/collection/service.py | 13 +++++++++---- watchapedia/app/collection/views.py | 12 +++++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/watchapedia/app/collection/repository.py b/watchapedia/app/collection/repository.py index 8deb36c..62a744a 100644 --- a/watchapedia/app/collection/repository.py +++ b/watchapedia/app/collection/repository.py @@ -76,6 +76,10 @@ def like_collection(self, user_id: int, collection: Collection) -> Collection: self.session.flush() return collection + def get_collections_by_user_id(self, user_id: int) -> Sequence[Collection]: + collections_list_query = select(Collection).where(Collection.user_id == user_id) + return self.session.scalars(collections_list_query).all() + def get_collection_by_collection_id(self, collection_id: int) -> Collection | None: get_collection_query = select(Collection).filter(Collection.id == collection_id) return self.session.scalar(get_collection_query) diff --git a/watchapedia/app/collection/service.py b/watchapedia/app/collection/service.py index f3740e8..74d4bdd 100644 --- a/watchapedia/app/collection/service.py +++ b/watchapedia/app/collection/service.py @@ -3,6 +3,7 @@ from datetime import datetime from watchapedia.app.collection.repository import CollectionRepository from watchapedia.app.collection.models import Collection +from watchapedia.app.user.models import User from watchapedia.app.collection.dto.responses import CollectionResponse, MovieCompactResponse from watchapedia.app.movie.repository import MovieRepository from watchapedia.app.movie.errors import MovieNotFoundError @@ -33,7 +34,7 @@ def create_collection( movies.append(movie) self.collection_repository.add_collection_movie(collection=collection, movies=movies) - return self._process_collection_process(collection) + return self._process_collection_response(collection) def update_collection( self, collection_id: int, user_id: int, title: int | None, overview: int | None, add_movie_ids: list[int] | None, delete_movie_ids: list[int] | None @@ -64,7 +65,7 @@ def get_collection_by_collection_id(self, collection_id: int) -> CollectionRespo collection = self.collection_repository.get_collection_by_collection_id(collection_id=collection_id) if collection is None: raise CollectionNotFoundError() - return self._process_collection_process(collection) + return self._process_collection_response(collection) def get_movie_ids_from_collection(self, collection_id: int) -> list[int] | None: collection = self.collection_repository.get_collection_by_collection_id(collection_id=collection_id) @@ -75,9 +76,13 @@ def get_movie_ids_from_collection(self, collection_id: int) -> list[int] | None: def like_collection(self, user_id: int, collection_id: int) -> CollectionResponse: collection = self.collection_repository.get_collection_by_collection_id(collection_id) updated_collection = self.collection_repository.like_collection(user_id, collection) - return self._process_collection_process(updated_collection) + return self._process_collection_response(updated_collection) - def _process_collection_process(self, collection: Collection) -> CollectionResponse: + def get_user_collections(self, user: User) -> list[CollectionResponse]: + collections = self.collection_repository.get_collections_by_user_id(user.id) + return [ self._process_collection_response(collection) for collection in collections ] + + def _process_collection_response(self, collection: Collection) -> CollectionResponse: return CollectionResponse( id=collection.id, user_id=collection.user_id, diff --git a/watchapedia/app/collection/views.py b/watchapedia/app/collection/views.py index 55aaf08..cd040c8 100644 --- a/watchapedia/app/collection/views.py +++ b/watchapedia/app/collection/views.py @@ -61,4 +61,14 @@ def like_collection( collection_id: int, collection_service: Annotated[CollectionService, Depends()], ) -> CollectionResponse: - return collection_service.like_collection(user.id, collection_id) \ No newline at end of file + return collection_service.like_collection(user.id, collection_id) + +@collection_router.get("", + status_code=200, + summary="유저 컬렉션 출력", + description="[로그인 필요] 유저가 만든 모든 컬렉션을 반환합니다.") +def get_collections_by_user( + user: Annotated[User, Depends(login_with_header)], + collection_service: Annotated[CollectionService, Depends()] +) -> list[CollectionResponse]: + return collection_service.get_user_collections(user) \ No newline at end of file From 66432d2c75957794d8cf51fb0eafab73fb4293e1 Mon Sep 17 00:00:00 2001 From: anandashin Date: Tue, 14 Jan 2025 21:35:10 +0900 Subject: [PATCH 10/26] =?UTF-8?q?feat:=20=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/collection/models.py | 2 +- watchapedia/app/collection/repository.py | 4 ++++ watchapedia/app/collection/service.py | 8 ++++++++ watchapedia/app/collection/views.py | 14 +++++++++++++- 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/watchapedia/app/collection/models.py b/watchapedia/app/collection/models.py index e3e6435..77916a8 100644 --- a/watchapedia/app/collection/models.py +++ b/watchapedia/app/collection/models.py @@ -51,7 +51,7 @@ class Collection(Base): user: Mapped["User"] = relationship("User", back_populates="collections") movies: Mapped[list["Movie"]] = relationship( - secondary="movie_collection", back_populates="collections", cascade="all, delete" + secondary="movie_collection", back_populates="collections" ) comments: Mapped[list["CollectionComment"]] = relationship( "CollectionComment", back_populates="collection", cascade="all, delete, delete-orphan" diff --git a/watchapedia/app/collection/repository.py b/watchapedia/app/collection/repository.py index 62a744a..0caaa60 100644 --- a/watchapedia/app/collection/repository.py +++ b/watchapedia/app/collection/repository.py @@ -87,3 +87,7 @@ def get_collection_by_collection_id(self, collection_id: int) -> Collection | No def get_movie_by_movie_id(self, movie_id: int) -> Movie | None: get_movie_query = select(Movie).filter(Movie.id==movie_id) return self.session.scalar(get_movie_query) + + def delete_collection_by_id(self, collection: Collection) -> None: + self.session.delete(collection) + self.session.flush() diff --git a/watchapedia/app/collection/service.py b/watchapedia/app/collection/service.py index 74d4bdd..63e00bb 100644 --- a/watchapedia/app/collection/service.py +++ b/watchapedia/app/collection/service.py @@ -82,6 +82,14 @@ def get_user_collections(self, user: User) -> list[CollectionResponse]: collections = self.collection_repository.get_collections_by_user_id(user.id) return [ self._process_collection_response(collection) for collection in collections ] + def delete_collection_by_id(self, collection_id: int, user: User) -> None: + collection = self.collection_repository.get_collection_by_collection_id(collection_id) + if collection is None: + raise CollectionNotFoundError() + if collection.user_id != user.id: + raise PermissionDeniedError() + self.collection_repository.delete_collection_by_id(collection) + def _process_collection_response(self, collection: Collection) -> CollectionResponse: return CollectionResponse( id=collection.id, diff --git a/watchapedia/app/collection/views.py b/watchapedia/app/collection/views.py index cd040c8..c9b0370 100644 --- a/watchapedia/app/collection/views.py +++ b/watchapedia/app/collection/views.py @@ -71,4 +71,16 @@ def get_collections_by_user( user: Annotated[User, Depends(login_with_header)], collection_service: Annotated[CollectionService, Depends()] ) -> list[CollectionResponse]: - return collection_service.get_user_collections(user) \ No newline at end of file + return collection_service.get_user_collections(user) + +@collection_router.delete("/{collection_id}", + status_code=204, + summary="컬렉션 삭제", + description="[로그인 필요] 컬렉션 id를 받아 해당 컬렉션을 삭제합니다. 성공 시 'Success' 반환") +def delete_collection( + collection_id: int, + user: Annotated[User, Depends(login_with_header)], + collection_service: Annotated[CollectionService, Depends()] +): + collection_service.delete_collection_by_id(collection_id, user) + return "Success" \ No newline at end of file From 0b523cf17b6e3aff1f37368443ae878d97022722 Mon Sep 17 00:00:00 2001 From: anandashin Date: Tue, 14 Jan 2025 22:33:06 +0900 Subject: [PATCH 11/26] =?UTF-8?q?feat:=20collection=5Fcomment=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC,=20collection=20comment=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/api.py | 4 +- watchapedia/app/collection/models.py | 29 +------ watchapedia/app/collection/repository.py | 3 +- watchapedia/app/collection/views.py | 4 +- .../app/collection_comment/dto/requests.py | 13 +++ .../app/collection_comment/dto/responses.py | 13 +++ watchapedia/app/collection_comment/models.py | 38 +++++++++ .../app/collection_comment/repository.py | 81 +++++++++++++++++++ watchapedia/app/collection_comment/service.py | 72 +++++++++++++++++ watchapedia/app/collection_comment/views.py | 74 +++++++++++++++++ watchapedia/database/__init__.py | 3 +- watchapedia/settings.py | 2 +- 12 files changed, 301 insertions(+), 35 deletions(-) create mode 100644 watchapedia/app/collection_comment/dto/requests.py create mode 100644 watchapedia/app/collection_comment/dto/responses.py create mode 100644 watchapedia/app/collection_comment/models.py create mode 100644 watchapedia/app/collection_comment/repository.py create mode 100644 watchapedia/app/collection_comment/service.py create mode 100644 watchapedia/app/collection_comment/views.py diff --git a/watchapedia/api.py b/watchapedia/api.py index fc3d7dc..b03533e 100644 --- a/watchapedia/api.py +++ b/watchapedia/api.py @@ -5,6 +5,7 @@ from watchapedia.app.comment.views import comment_router from watchapedia.app.participant.views import participant_router from watchapedia.app.collection.views import collection_router +from watchapedia.app.collection_comment.views import collection_comment_router api_router = APIRouter() @@ -13,4 +14,5 @@ api_router.include_router(review_router, prefix='/reviews', tags=['reviews']) api_router.include_router(comment_router, prefix='/comments', tags=['comments']) api_router.include_router(participant_router, prefix='/participants', tags=['participants']) -api_router.include_router(collection_router, prefix='/collections', tags=['collections']) \ No newline at end of file +api_router.include_router(collection_router, prefix='/collections', tags=['collections']) +api_router.include_router(collection_comment_router, prefix='/collection_comments', tags=['collection_comments']) \ No newline at end of file diff --git a/watchapedia/app/collection/models.py b/watchapedia/app/collection/models.py index 77916a8..cc8a11d 100644 --- a/watchapedia/app/collection/models.py +++ b/watchapedia/app/collection/models.py @@ -7,6 +7,7 @@ if TYPE_CHECKING: from watchapedia.app.movie.models import Movie from watchapedia.app.user.models import User + from watchapedia.app.collection_comment.models import CollectionComment class MovieCollection(Base): __tablename__ = 'movie_collection' @@ -18,24 +19,6 @@ class MovieCollection(Base): Integer, ForeignKey("collection.id", ondelete="CASCADE"), nullable=False, primary_key=True ) -class CollectionComment(Base): - __tablename__ = 'collection_comment' - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - content: Mapped[str] = mapped_column(String(500), nullable=False) - likes_count: Mapped[int] = mapped_column(Integer, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) - - user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False - ) - user: Mapped["User"] = relationship("User", back_populates="collection_comments") - - collection_id: Mapped[int] = mapped_column( - Integer, ForeignKey("collection.id", ondelete="CASCADE"), nullable=False - ) - collection: Mapped["Collection"] = relationship("Collection", back_populates="comments") - class Collection(Base): __tablename__ = 'collection' @@ -68,13 +51,3 @@ class UserLikesCollection(Base): Integer, ForeignKey("collection.id", ondelete="CASCADE"), nullable=False ) -class UserLikesCollectionComment(Base): - __tablename__ = 'user_likes_collection_comment' - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False - ) - collection_comment_id: Mapped[int] = mapped_column( - Integer, ForeignKey("collection_comment.id", ondelete="CASCADE"), nullable=False - ) diff --git a/watchapedia/app/collection/repository.py b/watchapedia/app/collection/repository.py index 0caaa60..4e41ca0 100644 --- a/watchapedia/app/collection/repository.py +++ b/watchapedia/app/collection/repository.py @@ -4,8 +4,7 @@ from watchapedia.database.connection import get_db_session from typing import Annotated, Sequence from watchapedia.app.movie.models import Movie -from watchapedia.app.collection.models import Collection, UserLikesCollection, UserLikesCollectionComment -from watchapedia.app.collection.errors import CollectionNotFoundError +from watchapedia.app.collection.models import Collection, UserLikesCollection from datetime import datetime class CollectionRepository(): diff --git a/watchapedia/app/collection/views.py b/watchapedia/app/collection/views.py index c9b0370..b62aa77 100644 --- a/watchapedia/app/collection/views.py +++ b/watchapedia/app/collection/views.py @@ -76,11 +76,11 @@ def get_collections_by_user( @collection_router.delete("/{collection_id}", status_code=204, summary="컬렉션 삭제", - description="[로그인 필요] 컬렉션 id를 받아 해당 컬렉션을 삭제합니다. 성공 시 'Success' 반환") + description="[로그인 필요] 컬렉션 id를 받아 해당 컬렉션을 삭제합니다. 성공 시 204 code 반환") def delete_collection( collection_id: int, user: Annotated[User, Depends(login_with_header)], collection_service: Annotated[CollectionService, Depends()] ): collection_service.delete_collection_by_id(collection_id, user) - return "Success" \ No newline at end of file + diff --git a/watchapedia/app/collection_comment/dto/requests.py b/watchapedia/app/collection_comment/dto/requests.py new file mode 100644 index 0000000..3f9b7cf --- /dev/null +++ b/watchapedia/app/collection_comment/dto/requests.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel +from watchapedia.common.errors import InvalidFieldFormatError +from typing import Annotated +from pydantic.functional_validators import AfterValidator + +def validate_content(value: str | None) -> str | None: + # content 필드는 500자 이하여야 함 + if value is None or len(value) > 500: + raise InvalidFieldFormatError("content") + return value + +class CollectionCommentRequest(BaseModel): + content: Annotated[str, AfterValidator(validate_content)] \ No newline at end of file diff --git a/watchapedia/app/collection_comment/dto/responses.py b/watchapedia/app/collection_comment/dto/responses.py new file mode 100644 index 0000000..7922f0d --- /dev/null +++ b/watchapedia/app/collection_comment/dto/responses.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel +from datetime import datetime +from watchapedia.app.user.models import User + +class CollectionCommentResponse(BaseModel): + id: int + user_id: int + user_name: str + collection_id: int + content: str + likes_count: int + created_at: datetime + diff --git a/watchapedia/app/collection_comment/models.py b/watchapedia/app/collection_comment/models.py new file mode 100644 index 0000000..fd74bc9 --- /dev/null +++ b/watchapedia/app/collection_comment/models.py @@ -0,0 +1,38 @@ +from sqlalchemy import Integer, String, ForeignKey +from sqlalchemy.orm import relationship, Mapped, mapped_column +from datetime import datetime +from watchapedia.database.common import Base +from sqlalchemy import DateTime +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from watchapedia.app.user.models import User + from watchapedia.app.collection.models import Collection + +class CollectionComment(Base): + __tablename__ = 'collection_comment' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + content: Mapped[str] = mapped_column(String(500), nullable=False) + likes_count: Mapped[int] = mapped_column(Integer, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False + ) + user: Mapped["User"] = relationship("User", back_populates="collection_comments") + + collection_id: Mapped[int] = mapped_column( + Integer, ForeignKey("collection.id", ondelete="CASCADE"), nullable=False + ) + collection: Mapped["Collection"] = relationship("Collection", back_populates="comments") + +class UserLikesCollectionComment(Base): + __tablename__ = 'user_likes_collection_comment' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False + ) + collection_comment_id: Mapped[int] = mapped_column( + Integer, ForeignKey("collection_comment.id", ondelete="CASCADE"), nullable=False + ) diff --git a/watchapedia/app/collection_comment/repository.py b/watchapedia/app/collection_comment/repository.py new file mode 100644 index 0000000..ff9c52a --- /dev/null +++ b/watchapedia/app/collection_comment/repository.py @@ -0,0 +1,81 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session +from fastapi import Depends +from watchapedia.database.connection import get_db_session +from typing import Annotated, Sequence +from datetime import datetime +from watchapedia.app.collection_comment.models import CollectionComment, UserLikesCollectionComment +from watchapedia.app.comment.errors import CommentNotFoundError + +class CollectionCommentRepository(): + def __init__(self, session: Annotated[Session, Depends(get_db_session)]) -> None: + self.session = session + + def get_comment_by_user_and_collection(self, user_id: int, collection_id: int) -> CollectionComment | None: + get_comment_query = select(CollectionComment).filter( + (CollectionComment.user_id == user_id) + & (CollectionComment.collection_id == collection_id) + ) + + return self.session.scalar(get_comment_query) + + def create_comment(self, user_id: int, collection_id: int, content: str, created_at) -> CollectionComment: + comment = CollectionComment( + user_id=user_id, + collection_id=collection_id, + content=content, + likes_count=0, + created_at=created_at + ) + self.session.add(comment) + self.session.flush() + + comment = self.get_comment_by_user_and_collection(user_id, collection_id) + + return comment + + def update_comment(self, comment: CollectionComment, content: str) -> CollectionComment: + comment.content = content + self.session.flush() + + return comment + + def get_comments(self, collection_id: int) -> Sequence[CollectionComment]: + comments_list_query = select(CollectionComment).where(CollectionComment.collection_id == collection_id) + return self.session.scalars(comments_list_query).all() + + def get_comment_by_comment_id(self, comment_id: int) -> CollectionComment: + comment = self.session.get(CollectionComment, comment_id) + if comment is None : + raise CommentNotFoundError() + + return comment + + def like_comment(self, user_id: int, comment: CollectionComment) -> CollectionComment: + get_like_query = select(UserLikesCollectionComment).filter( + (UserLikesCollectionComment.user_id == user_id) + & (UserLikesCollectionComment.collection_comment_id == comment.id) + ) + user_likes_comment = self.session.scalar(get_like_query) + + if user_likes_comment is None : + user_likes_comment = UserLikesCollectionComment( + user_id=user_id, + collection_comment_id=comment.id, + ) + self.session.add(user_likes_comment) + + comment.likes_count += 1 + + else : + self.session.delete(user_likes_comment) + + comment.likes_count -= 1 + + self.session.flush() + + return comment + + def delete_comment_by_id(self, comment: CollectionComment) -> None: + self.session.delete(comment) + self.session.flush() \ No newline at end of file diff --git a/watchapedia/app/collection_comment/service.py b/watchapedia/app/collection_comment/service.py new file mode 100644 index 0000000..f0c4950 --- /dev/null +++ b/watchapedia/app/collection_comment/service.py @@ -0,0 +1,72 @@ +from typing import Annotated +from fastapi import Depends +from watchapedia.common.errors import PermissionDeniedError +from watchapedia.app.collection.repository import CollectionRepository +from watchapedia.app.collection.errors import CollectionNotFoundError +from watchapedia.app.collection_comment.dto.responses import CollectionCommentResponse +from watchapedia.app.collection_comment.repository import CollectionCommentRepository +from watchapedia.app.collection_comment.models import CollectionComment +from watchapedia.app.user.models import User +from watchapedia.app.comment.errors import RedundantCommentError, CommentNotFoundError +from datetime import datetime + +class CollectionCommentService: + def __init__(self, + collection_repository: Annotated[CollectionRepository, Depends()], + collection_comment_repository: Annotated[CollectionCommentRepository, Depends()] + ) -> None: + self.collection_repository = collection_repository + self.collection_comment_repository = collection_comment_repository + + def create_comment(self, user_id: int, collection_id: int, content: str) -> CollectionCommentResponse: + collection = self.collection_repository.get_collection_by_collection_id(collection_id) + if collection is None : + raise CollectionNotFoundError() + + new_comment = self.collection_comment_repository.create_comment(user_id=user_id, collection_id=collection_id, + content=content, created_at=datetime.now()) + + return self._process_comment_response(new_comment) + + def update_comment(self, user_id: int, comment_id: int, content: str) -> CollectionCommentResponse: + comment = self.collection_comment_repository.get_comment_by_comment_id(comment_id) + if not comment.user_id == user_id : + raise PermissionDeniedError() + + updated_comment = self.collection_comment_repository.update_comment(comment, content=content) + return self._process_comment_response(updated_comment) + + def list_comments(self, collection_id: int) -> list[CollectionCommentResponse]: + collection = self.collection_repository.get_collection_by_collection_id(collection_id) + if collection is None : + raise CollectionNotFoundError() + + comments = self.collection_comment_repository.get_comments(collection_id) + return [self._process_comment_response(comment) for comment in comments] + + def like_comment(self, user_id: int, comment_id: int) -> CollectionCommentResponse : + comment = self.collection_comment_repository.get_comment_by_comment_id(comment_id) + if comment is None : + raise CommentNotFoundError() + + updated_comment = self.collection_comment_repository.like_comment(user_id, comment) + return self._process_comment_response(updated_comment) + + def delete_comment_by_id(self, comment_id: int, user: User) -> None: + comment = self.collection_comment_repository.get_comment_by_comment_id(comment_id) + if comment is None: + raise CommentNotFoundError() + if comment.user_id != user.id: + raise PermissionDeniedError() + self.collection_comment_repository.delete_comment_by_id(comment) + + def _process_comment_response(self, comment: CollectionComment) -> CollectionCommentResponse: + return CollectionCommentResponse( + id=comment.id, + user_id=comment.user.id, + user_name=comment.user.username, + collection_id=comment.collection_id, + content=comment.content, + likes_count=comment.likes_count, + created_at=comment.created_at + ) diff --git a/watchapedia/app/collection_comment/views.py b/watchapedia/app/collection_comment/views.py new file mode 100644 index 0000000..b2dcca4 --- /dev/null +++ b/watchapedia/app/collection_comment/views.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, Depends +from typing import Annotated +from datetime import datetime +from watchapedia.app.user.views import login_with_header +from watchapedia.app.user.models import User +from watchapedia.app.collection_comment.dto.requests import CollectionCommentRequest +from watchapedia.app.collection_comment.dto.responses import CollectionCommentResponse +from watchapedia.app.collection_comment.models import CollectionComment +from watchapedia.app.collection_comment.service import CollectionCommentService + +collection_comment_router = APIRouter() + +@collection_comment_router.post('/{collection_id}', + status_code=201, + summary="코멘트 작성", + description="[로그인 필요] collection_id, content를 받아 코멘트를 작성하고 성공 시 username을 포함하여 코멘트를 반환합니다.", + ) +def create_comment( + user: Annotated[User, Depends(login_with_header)], + collection_comment_service: Annotated[CollectionCommentService, Depends()], + collection_id: int, + comment: CollectionCommentRequest, +) -> CollectionCommentResponse: + commentresponse = collection_comment_service.create_comment(user.id, collection_id, comment.content) + return commentresponse + +@collection_comment_router.patch('/{comment_id}', + status_code=200, + summary="코멘트 수정", + description="[로그인 필요] comment_id와 content를 받아 코멘트를 수정하고 반환합니다.", + ) +def update_comment( + user: Annotated[User, Depends(login_with_header)], + collection_comment_service: Annotated[CollectionCommentService, Depends()], + comment_id: int, + comment: CollectionCommentRequest, +) -> CollectionCommentResponse: + return collection_comment_service.update_comment( + user.id, comment_id, comment.content + ) + +@collection_comment_router.get('/{collection_id}', + status_code=200, + summary="코멘트 출력", + description="collection_id를 받아 해당 리뷰에 달린 코멘트들을 반환합니다", + ) +def get_comments( + collection_id: int, + collection_comment_service: Annotated[CollectionCommentService, Depends()], +) -> list[CollectionCommentResponse]: + return collection_comment_service.list_comments(collection_id) + +@collection_comment_router.patch('/like/{comment_id}', + status_code=200, + summary="코멘트 추천/취소", + description="[로그인 필요] comment_id를 받아 추천되어 있지 않으면 추천하고, 추천되어 있으면 취소합니다.", + ) +def like_comment( + user: Annotated[User, Depends(login_with_header)], + comment_id: int, + collection_comment_service: Annotated[CollectionCommentService, Depends()], +) -> CollectionCommentResponse: + return collection_comment_service.like_comment(user.id, comment_id) + +@collection_comment_router.delete('/{comment_id}', + status_code=204, + summary="코멘트 삭제", + description="[로그인 필요] 코멘트 id를 받아 해당 코멘트을 삭제합니다. 성공 시 204 code 반환") +def delete_comment( + collection_id: int, + user: Annotated[User, Depends(login_with_header)], + collection_comment_service: Annotated[CollectionCommentService, Depends()] +): + collection_comment_service.delete_comment_by_id(collection_id, user) \ No newline at end of file diff --git a/watchapedia/database/__init__.py b/watchapedia/database/__init__.py index 6e457dd..67dcd3e 100644 --- a/watchapedia/database/__init__.py +++ b/watchapedia/database/__init__.py @@ -5,4 +5,5 @@ import watchapedia.app.participant.models import watchapedia.app.review.models import watchapedia.app.comment.models -import watchapedia.app.collection.models \ No newline at end of file +import watchapedia.app.collection.models +import watchapedia.app.collection_comment.models \ No newline at end of file diff --git a/watchapedia/settings.py b/watchapedia/settings.py index 138382a..de07509 100644 --- a/watchapedia/settings.py +++ b/watchapedia/settings.py @@ -2,7 +2,7 @@ from pydantic_settings import BaseSettings -ENV = os.getenv("ENV", "local") # 배포 시에는(merge) prod로 바꿔주세요 +ENV = os.getenv("ENV", "prod") # 배포 시에는(merge) prod로 바꿔주세요 assert ENV in ("local", "prod") From a7ff13435ccf396d89c057c0056ec3a5334407fc Mon Sep 17 00:00:00 2001 From: deveroskp Date: Thu, 16 Jan 2025 02:27:05 +0900 Subject: [PATCH 12/26] =?UTF-8?q?Feat:=20=EC=9C=A0=EC=A0=80=20=ED=8C=94?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?&=20=EC=9C=A0=EC=A0=80=20=ED=94=84=EB=A1=9C=ED=95=84=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/user/dto/requests.py | 4 +- watchapedia/app/user/dto/responses.py | 23 ++++++++ watchapedia/app/user/errors.py | 14 ++++- watchapedia/app/user/models.py | 16 +++++- watchapedia/app/user/repository.py | 54 +++++++++++++++++- watchapedia/app/user/service.py | 55 ++++++++++++++++++- watchapedia/app/user/views.py | 53 +++++++++++++++++- ...2025_01_16_0154-f11b9b212861_add_follow.py | 40 ++++++++++++++ 8 files changed, 246 insertions(+), 13 deletions(-) create mode 100644 watchapedia/database/alembic/versions/2025_01_16_0154-f11b9b212861_add_follow.py diff --git a/watchapedia/app/user/dto/requests.py b/watchapedia/app/user/dto/requests.py index 1ef089e..dc3cf04 100644 --- a/watchapedia/app/user/dto/requests.py +++ b/watchapedia/app/user/dto/requests.py @@ -3,6 +3,7 @@ import re from typing import Annotated from pydantic.functional_validators import AfterValidator +from watchapedia.app.participant.dto.requests import validate_url USERNAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{3,20}$") LOGIN_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_.]{6,20}$") @@ -64,4 +65,5 @@ class UserSigninRequest(BaseModel): class UserUpdateRequest(BaseModel): username: Annotated[str | None, AfterValidator(validate_username)] = None - login_password: Annotated[str | None, AfterValidator(validate_password)] = None \ No newline at end of file + login_password: Annotated[str | None, AfterValidator(validate_password)] = None + profile_url: Annotated[str | None, AfterValidator(validate_url)] = None \ No newline at end of file diff --git a/watchapedia/app/user/dto/responses.py b/watchapedia/app/user/dto/responses.py index d84736f..ddcf8fc 100644 --- a/watchapedia/app/user/dto/responses.py +++ b/watchapedia/app/user/dto/responses.py @@ -3,6 +3,7 @@ class MyProfileResponse(BaseModel): username: str login_id: str + profile_url: str | None = None @staticmethod def from_user(user) -> 'MyProfileResponse': @@ -11,6 +12,28 @@ def from_user(user) -> 'MyProfileResponse': login_id=user.login_id, ) +class UserProfileResponse(BaseModel): + username: str + login_id: str + profile_url: str | None = None + following_count: int | None = None + follower_count: int | None = None + review_count: int | None = None + comment_count: int | None = None + collection_count: int | None = None + + @staticmethod + def from_user(user, following_count, follower_count, review_count, comment_count, collection_count) -> 'UserProfileResponse': + return UserProfileResponse( + username=user.username, + login_id=user.login_id, + profile_url=user.profile_url, + following_count=following_count, + follower_count=follower_count, + review_count=review_count, + comment_count=comment_count, + collection_count=collection_count + ) class UserSigninResponse(BaseModel): access_token: str refresh_token: str \ No newline at end of file diff --git a/watchapedia/app/user/errors.py b/watchapedia/app/user/errors.py index f9cc9e9..44e55ec 100644 --- a/watchapedia/app/user/errors.py +++ b/watchapedia/app/user/errors.py @@ -10,4 +10,16 @@ def __init__(self): class UserNotFoundError(HTTPException): def __init__(self): - super().__init__(status_code=404, detail="User not found") \ No newline at end of file + super().__init__(status_code=404, detail="User not found") + +class UserAlreadyFollowingError(HTTPException): + def __init__(self): + super().__init__(status_code=409, detail="User already following") + +class UserAlreadyNotFollowingError(HTTPException): + def __init__(self): + super().__init__(status_code=409, detail="User already not following") + +class CANNOT_FOLLOW_MYSELF_Error(HTTPException): + def __init__(self): + super().__init__(status_code=409, detail="Cannot follow myself") \ No newline at end of file diff --git a/watchapedia/app/user/models.py b/watchapedia/app/user/models.py index 0232237..c3eadab 100644 --- a/watchapedia/app/user/models.py +++ b/watchapedia/app/user/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Integer, String, ForeignKey +from sqlalchemy import Integer, String, ForeignKey, UniqueConstraint from sqlalchemy.orm import relationship, Mapped, mapped_column from datetime import datetime from sqlalchemy import DateTime @@ -16,6 +16,7 @@ class User(Base): username: Mapped[str] = mapped_column(String(50), nullable=False) login_id: Mapped[str] = mapped_column(String(50), nullable=False) hashed_pwd: Mapped[str] = mapped_column(String(100), nullable=False) + profile_url: Mapped[str | None] = mapped_column(String(500), nullable=True) reviews: Mapped[list["Review"]] = relationship("Review", back_populates="user") comments: Mapped[list["Comment"]] = relationship("Comment", back_populates="user") @@ -30,4 +31,15 @@ class BlockedToken(Base): __tablename__ = "blocked_token" token_id: Mapped[str] = mapped_column(String(255), primary_key=True) - expired_at: Mapped[datetime] = mapped_column(DateTime) \ No newline at end of file + expired_at: Mapped[datetime] = mapped_column(DateTime) + +class Follow(Base): + __tablename__ = "follow" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + follower_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"), nullable=False) + following_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"), nullable=False) + + __table_args__ = ( + UniqueConstraint('follower_id', 'following_id', name='uq_follower_following'), + ) \ No newline at end of file diff --git a/watchapedia/app/user/repository.py b/watchapedia/app/user/repository.py index 44ceefa..6a8d11b 100644 --- a/watchapedia/app/user/repository.py +++ b/watchapedia/app/user/repository.py @@ -1,9 +1,12 @@ -from sqlalchemy import select +from sqlalchemy import select, func from sqlalchemy.orm import Session from fastapi import Depends from watchapedia.database.connection import get_db_session from typing import Annotated -from watchapedia.app.user.models import User, BlockedToken +from watchapedia.app.user.models import User, BlockedToken, Follow +from watchapedia.app.review.models import Review +from watchapedia.app.comment.models import Comment +from watchapedia.app.collection.models import Collection from passlib.context import CryptContext from watchapedia.auth.utils import create_hashed_password from datetime import datetime @@ -40,6 +43,45 @@ def update_user(self, user_id:int, username: str | None, login_password: str | N if login_password is not None: user.hashed_pwd = create_hashed_password(login_password) + def follow(self, follower_id: int, following_id: int) -> None: + follow = Follow(follower_id=follower_id, following_id=following_id) + self.session.add(follow) + + def unfollow(self, follower_id: int, following_id: int) -> None: + unfollow_query = select(Follow).filter( + (Follow.follower_id == follower_id) & (Follow.following_id == following_id) + ) + unfollow = self.session.scalar(unfollow_query) + self.session.delete(unfollow) + + def get_followings(self, user_id: int) -> list[User]: + get_followings_query = select(User).join(Follow, Follow.following_id == User.id).filter(Follow.follower_id == user_id) + return self.session.scalars(get_followings_query).all() + + def get_followers(self, user_id: int) -> list[User]: + get_followers_query = select(User).join(Follow, Follow.follower_id == User.id).filter(Follow.following_id == user_id) + return self.session.scalars(get_followers_query).all() + + def get_followings_count(self, user_id: int) -> int: + get_following_count_query = select(func.count()).select_from(Follow).where(Follow.follower_id == user_id) + return self.session.scalar(get_following_count_query) + + def get_followers_count(self, user_id: int) -> int: + get_followers_count_query = select(func.count()).select_from(Follow).where(Follow.following_id == user_id) + return self.session.scalar(get_followers_count_query) + + def get_reviews_count(self, user_id: int) -> int: + count_query = select(func.count()).select_from(Review).where(Review.user_id == user_id) + return self.session.scalar(count_query) + + def get_comments_count(self, user_id: int) -> int: + count_query = select(func.count()).select_from(Comment).where(Comment.user_id == user_id) + return self.session.scalar(count_query) + + def get_collections_count(self, user_id: int) -> int: + count_query = select(func.count()).select_from(Collection).where(Collection.user_id == user_id) + return self.session.scalar(count_query) + def get_user_by_login_id(self, login_id: str) -> User | None: get_user_query = select(User).filter(User.login_id == login_id) return self.session.scalar(get_user_query) @@ -63,4 +105,10 @@ def is_token_blocked(self, token_id: str) -> bool: select(BlockedToken).where(BlockedToken.token_id == token_id) ) is not None - ) \ No newline at end of file + ) + + def is_following(self, follower_id: int, following_id: int) -> bool: + follow_query = select(Follow).filter( + (Follow.follower_id == follower_id) & (Follow.following_id == following_id) + ) + return self.session.scalar(follow_query) is not None \ No newline at end of file diff --git a/watchapedia/app/user/service.py b/watchapedia/app/user/service.py index 7542c2b..f510f5c 100644 --- a/watchapedia/app/user/service.py +++ b/watchapedia/app/user/service.py @@ -2,7 +2,8 @@ from watchapedia.app.user.repository import UserRepository from fastapi import Depends from watchapedia.common.errors import InvalidCredentialsError, InvalidTokenError, BlockedTokenError -from watchapedia.app.user.errors import UserAlreadyExistsError +from watchapedia.app.user.errors import UserAlreadyExistsError, UserNotFoundError, UserAlreadyFollowingError, UserAlreadyNotFollowingError, CANNOT_FOLLOW_MYSELF_Error +from watchapedia.app.user.dto.responses import MyProfileResponse from watchapedia.auth.utils import verify_password from watchapedia.app.user.models import User from watchapedia.auth.utils import create_access_token, create_refresh_token, decode_token @@ -22,10 +23,51 @@ def signin(self, login_id: str, login_password: str) -> tuple[str, str]: user = self.get_user_by_login_id(login_id) if user is None or verify_password(login_password, user.hashed_pwd) is False: raise InvalidCredentialsError() - # access token은 10분, refresh token은 24시간 유효한 토큰 생성 return self.issue_token(login_id) + def follow(self, follower_id: int, following_id: int) -> None: + if self.get_user_by_user_id(following_id) is None: + raise UserNotFoundError() + print(follower_id, following_id) + if follower_id == following_id: + raise CANNOT_FOLLOW_MYSELF_Error() + if self.user_repository.is_following(follower_id, following_id): + raise UserAlreadyFollowingError() + self.user_repository.follow(follower_id, following_id) + + def unfollow(self, follower_id: int, following_id: int) -> None: + if self.get_user_by_user_id(following_id) is None: + raise UserNotFoundError() + if follower_id == following_id: + return CANNOT_FOLLOW_MYSELF_Error() + if not self.user_repository.is_following(follower_id, following_id): + return UserAlreadyNotFollowingError() + self.user_repository.unfollow(follower_id, following_id) + + def get_followings(self, user_id: int) -> list[MyProfileResponse]: + users= self.user_repository.get_followings(user_id) + return [self._process_user_response(user) for user in users] + + def get_followers(self, user_id: int) -> list[MyProfileResponse]: + users = self.user_repository.get_followers(user_id) + return [self._process_user_response(user) for user in users] + + def get_followings_count(self, user_id: int) -> int: + return self.user_repository.get_followings_count(user_id) + + def get_followers_count(self, user_id: int) -> int: + return self.user_repository.get_followers_count(user_id) + + def get_reviews_count(self, user_id: int) -> int: + return self.user_repository.get_reviews_count(user_id) + + def get_comments_count(self, user_id: int) -> int: + return self.user_repository.get_comments_count(user_id) + + def get_collections_count(self, user_id: int) -> int: + return self.user_repository.get_collections_count(user_id) + def update_user(self, user_id:int, username: str | None, login_password: str | None) -> None: self.user_repository.update_user(user_id, username, login_password) @@ -67,4 +109,11 @@ def reissue_token(self, refresh_token: str) -> tuple[str, str]: return self.issue_token(login_id) def block_refresh_token(self, token_id: str, expired_at: datetime) -> None: - self.user_repository.block_token(token_id, expired_at) \ No newline at end of file + self.user_repository.block_token(token_id, expired_at) + + def _process_user_response(self, user: User) -> MyProfileResponse: + return MyProfileResponse( + username=user.username, + login_id=user.login_id, + profile_url=user.profile_url + ) \ No newline at end of file diff --git a/watchapedia/app/user/views.py b/watchapedia/app/user/views.py index e23eeec..6ec81aa 100644 --- a/watchapedia/app/user/views.py +++ b/watchapedia/app/user/views.py @@ -1,12 +1,13 @@ from fastapi import APIRouter, Depends, Cookie, Request from fastapi.responses import JSONResponse from watchapedia.app.user.dto.requests import UserSignupRequest, UserSigninRequest, UserUpdateRequest -from watchapedia.app.user.dto.responses import UserSigninResponse, MyProfileResponse +from watchapedia.app.user.dto.responses import UserSigninResponse, MyProfileResponse, UserProfileResponse from watchapedia.app.user.models import User from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from typing import Annotated from watchapedia.app.user.service import UserService -from watchapedia.app.user.errors import InvalidTokenError +from watchapedia.app.user.errors import InvalidTokenError, UserNotFoundError +from watchapedia.common.errors import InvalidCredentialsError from watchapedia.auth.settings import JWT_SETTINGS from datetime import datetime @@ -81,7 +82,7 @@ def signin( def me( user: User = Depends(login_with_header) ): - return MyProfileResponse(username=user.username, login_id=user.login_id, hashed_pwd=user.hashed_pwd) + return MyProfileResponse(username=user.username, login_id=user.login_id, profile_url=user.profile_url) @user_router.patch('/me', status_code=200, summary="내 정보 수정", description="access_token을 헤더에 담아 요청하면 내 정보를 수정하고 성공 시 'Success'를 반환합니다.") def update_me( @@ -92,6 +93,52 @@ def update_me( user_service.update_user(user.id, username= update_request.username, login_password=update_request.login_password) return "Success" +@user_router.post('/follow/{follow_user_id}', status_code=201, summary="팔로우", description="user_id를 받아 해당 유저를 팔로우하고 성공 시 'Success'를 반환합니다.") +def follow( + follow_user_id: int, + user_service: Annotated[UserService, Depends()], + user: User = Depends(login_with_header), +): + user_service.follow(user.id, follow_user_id) + return "Success" + +@user_router.delete('/follow/{follow_user_id}', status_code=200, summary="언팔로우", description="user_id를 받아 해당 유저를 언팔로우하고 성공 시 'Success'를 반환합니다.") +def unfollow( + follow_user_id: int, + user_service: Annotated[UserService, Depends()], + user: User = Depends(login_with_header), +): + user_service.unfollow(user.id, follow_user_id) + return "Success" + +@user_router.get('/followings', status_code=200, summary="팔로잉 목록", description="access_token을 헤더에 담아 요청하면 내가 팔로잉하는 유저들의 목록을 반환합니다.") +def followings( + user: Annotated[User, Depends(login_with_header)], + user_service: Annotated[UserService, Depends()], +) -> list[MyProfileResponse]: + return user_service.get_followings(user.id) + +@user_router.get('/followers', status_code=200, summary="팔로워 목록", description="access_token을 헤더에 담아 요청하면 나를 팔로우하는 유저들의 목록을 반환합니다.") +def followers( + user: Annotated[User, Depends(login_with_header)], + user_service: Annotated[UserService, Depends()], +): + return user_service.get_followers(user.id) + +@user_router.get('/profile/{user_id}', status_code=200, summary="프로필 조회", description="user_id를 받아 해당 유저의 프로필 정보를 반환합니다.") +def profile( + user_id: int, + user_service: Annotated[UserService, Depends()], +): + user = user_service.get_user_by_user_id(user_id) + if user is None: + raise UserNotFoundError() + following_count = user_service.get_followings_count(user_id) + follwer_count = user_service.get_followers_count(user_id) + review_count = user_service.get_reviews_count(user_id) + comment_count = user_service.get_comments_count(user_id) + collection_count = user_service.get_collections_count(user_id) + return UserProfileResponse.from_user(user, following_count, follwer_count, review_count, comment_count, collection_count) @user_router.get('/refresh', status_code=200, diff --git a/watchapedia/database/alembic/versions/2025_01_16_0154-f11b9b212861_add_follow.py b/watchapedia/database/alembic/versions/2025_01_16_0154-f11b9b212861_add_follow.py new file mode 100644 index 0000000..71b293f --- /dev/null +++ b/watchapedia/database/alembic/versions/2025_01_16_0154-f11b9b212861_add_follow.py @@ -0,0 +1,40 @@ +"""add follow + +Revision ID: f11b9b212861 +Revises: ac577377af66 +Create Date: 2025-01-16 01:54:58.315239 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f11b9b212861' +down_revision: Union[str, None] = 'ac577377af66' +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! ### + op.create_table('follow', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('follower_id', sa.Integer(), nullable=False), + sa.Column('following_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['follower_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['following_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('follower_id', 'following_id', name='uq_follower_following') + ) + op.add_column('user', sa.Column('profile_url', sa.String(length=500), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'profile_url') + op.drop_table('follow') + # ### end Alembic commands ### From 67df194a922df1c973d9761b40c7346e506c7dd7 Mon Sep 17 00:00:00 2001 From: deveroskp Date: Thu, 16 Jan 2025 02:38:09 +0900 Subject: [PATCH 13/26] =?UTF-8?q?Fix:=20=EC=98=A4=ED=83=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/user/service.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/watchapedia/app/user/service.py b/watchapedia/app/user/service.py index f510f5c..3a8ffe5 100644 --- a/watchapedia/app/user/service.py +++ b/watchapedia/app/user/service.py @@ -29,7 +29,6 @@ def signin(self, login_id: str, login_password: str) -> tuple[str, str]: def follow(self, follower_id: int, following_id: int) -> None: if self.get_user_by_user_id(following_id) is None: raise UserNotFoundError() - print(follower_id, following_id) if follower_id == following_id: raise CANNOT_FOLLOW_MYSELF_Error() if self.user_repository.is_following(follower_id, following_id): @@ -40,9 +39,9 @@ def unfollow(self, follower_id: int, following_id: int) -> None: if self.get_user_by_user_id(following_id) is None: raise UserNotFoundError() if follower_id == following_id: - return CANNOT_FOLLOW_MYSELF_Error() + raise CANNOT_FOLLOW_MYSELF_Error() if not self.user_repository.is_following(follower_id, following_id): - return UserAlreadyNotFollowingError() + raise UserAlreadyNotFollowingError() self.user_repository.unfollow(follower_id, following_id) def get_followings(self, user_id: int) -> list[MyProfileResponse]: From a171b8a079679b42b1d92c46249509be0627839a Mon Sep 17 00:00:00 2001 From: Jiwon Shin <142284606+anandashin@users.noreply.github.com> Date: Thu, 16 Jan 2025 17:08:37 +0900 Subject: [PATCH 14/26] Feat/collection (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: collection 관련 모델 구현 * alembic versions file update * feat: requests.py 추가 * fix: collection 관련 모델 수정 - ondelete, cascade * feat: 컬렉션 생성 구현 * feat: search_collection 구현 * feat: update collection 구현 * feat: user_likes_collection 모델 추가, like_collection 구현 * feat: 유저 컬렉션 출력 구현 * feat: 컬렉션 삭제 구현 * feat: collection_comment 분리, collection comment 기능 구현 --- watchapedia/api.py | 6 +- watchapedia/app/collection/dto/requests.py | 32 +++++ watchapedia/app/collection/dto/responses.py | 18 +++ watchapedia/app/collection/errors.py | 18 +++ watchapedia/app/collection/models.py | 53 +++++++++ watchapedia/app/collection/repository.py | 92 +++++++++++++++ watchapedia/app/collection/service.py | 111 ++++++++++++++++++ watchapedia/app/collection/views.py | 86 ++++++++++++++ .../app/collection_comment/dto/requests.py | 13 ++ .../app/collection_comment/dto/responses.py | 13 ++ watchapedia/app/collection_comment/models.py | 38 ++++++ .../app/collection_comment/repository.py | 81 +++++++++++++ watchapedia/app/collection_comment/service.py | 72 ++++++++++++ watchapedia/app/collection_comment/views.py | 74 ++++++++++++ watchapedia/app/movie/models.py | 2 + watchapedia/app/user/models.py | 7 ++ watchapedia/database/__init__.py | 4 +- ..._01_14_1446-e9b6097534f2_collection_etc.py | 68 +++++++++++ ..._01_14_1509-65dc72edb8c5_fix_collection.py | 56 +++++++++ ...35-ac577377af66_add_userlikescollection.py | 37 ++++++ 20 files changed, 879 insertions(+), 2 deletions(-) create mode 100644 watchapedia/app/collection/dto/requests.py create mode 100644 watchapedia/app/collection/dto/responses.py create mode 100644 watchapedia/app/collection/errors.py create mode 100644 watchapedia/app/collection/models.py create mode 100644 watchapedia/app/collection/repository.py create mode 100644 watchapedia/app/collection/service.py create mode 100644 watchapedia/app/collection/views.py create mode 100644 watchapedia/app/collection_comment/dto/requests.py create mode 100644 watchapedia/app/collection_comment/dto/responses.py create mode 100644 watchapedia/app/collection_comment/models.py create mode 100644 watchapedia/app/collection_comment/repository.py create mode 100644 watchapedia/app/collection_comment/service.py create mode 100644 watchapedia/app/collection_comment/views.py create mode 100644 watchapedia/database/alembic/versions/2025_01_14_1446-e9b6097534f2_collection_etc.py create mode 100644 watchapedia/database/alembic/versions/2025_01_14_1509-65dc72edb8c5_fix_collection.py create mode 100644 watchapedia/database/alembic/versions/2025_01_14_2035-ac577377af66_add_userlikescollection.py diff --git a/watchapedia/api.py b/watchapedia/api.py index 315edea..b03533e 100644 --- a/watchapedia/api.py +++ b/watchapedia/api.py @@ -4,6 +4,8 @@ from watchapedia.app.review.views import review_router from watchapedia.app.comment.views import comment_router from watchapedia.app.participant.views import participant_router +from watchapedia.app.collection.views import collection_router +from watchapedia.app.collection_comment.views import collection_comment_router api_router = APIRouter() @@ -11,4 +13,6 @@ api_router.include_router(movie_router, prefix='/movies', tags=['movies']) api_router.include_router(review_router, prefix='/reviews', tags=['reviews']) api_router.include_router(comment_router, prefix='/comments', tags=['comments']) -api_router.include_router(participant_router, prefix='/participants', tags=['participants']) \ No newline at end of file +api_router.include_router(participant_router, prefix='/participants', tags=['participants']) +api_router.include_router(collection_router, prefix='/collections', tags=['collections']) +api_router.include_router(collection_comment_router, prefix='/collection_comments', tags=['collection_comments']) \ No newline at end of file diff --git a/watchapedia/app/collection/dto/requests.py b/watchapedia/app/collection/dto/requests.py new file mode 100644 index 0000000..20b60a4 --- /dev/null +++ b/watchapedia/app/collection/dto/requests.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel +from watchapedia.common.errors import InvalidFieldFormatError +from typing import Annotated +from pydantic.functional_validators import AfterValidator + +def validate_title(value: str | None) -> str | None: + # title는 100자 이내여야 함 + if value is None: + return value + if len(value) > 100: + raise InvalidFieldFormatError("title") + return value + +def validate_overview(value: str | None) -> str | None: + # overview는 500자 이내여야 함 + if value is None: + return value + if len(value) > 500: + raise InvalidFieldFormatError("overview") + return value + + +class CollectionCreateRequest(BaseModel): + title: Annotated[str, AfterValidator(validate_title)] + overview: Annotated[str | None, AfterValidator(validate_overview)] = None + movie_ids: list[int] | None = None + +class CollectionUpdateRequest(BaseModel): + title: Annotated[str | None, AfterValidator(validate_title)] = None + overview: Annotated[str | None, AfterValidator(validate_overview)] = None + add_movie_ids: list[int] | None = None + delete_movie_ids: list[int] | None = None diff --git a/watchapedia/app/collection/dto/responses.py b/watchapedia/app/collection/dto/responses.py new file mode 100644 index 0000000..833d136 --- /dev/null +++ b/watchapedia/app/collection/dto/responses.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel +from datetime import datetime + +class MovieCompactResponse(BaseModel): + id: int + title: str + poster_url: str | None + average_rating: float | None + +class CollectionResponse(BaseModel): + id: int + user_id: int + title: str + overview: str | None + likes_count: int + comments_count: int + created_at: datetime + movies: list[MovieCompactResponse] \ No newline at end of file diff --git a/watchapedia/app/collection/errors.py b/watchapedia/app/collection/errors.py new file mode 100644 index 0000000..498ca58 --- /dev/null +++ b/watchapedia/app/collection/errors.py @@ -0,0 +1,18 @@ +from fastapi import HTTPException + +class CollectionNotFoundError(HTTPException): + def __init__(self): + super().__init__(status_code=404, detail="Collection not found") + +class InvalidFormatError(HTTPException): + def __init__(self): + super().__init__(status_code=400, detail="Collection Invalid Format") + +class NoSuchMovieError(HTTPException): + def __init__(self): + super().__init__(status_code=400, detail="No Such Movie in the Collection") + +class MovieAlreadyExistsError(HTTPException): + def __init__(self): + super().__init__(status_code=409, detail="Movie already exists in the Collection") + diff --git a/watchapedia/app/collection/models.py b/watchapedia/app/collection/models.py new file mode 100644 index 0000000..cc8a11d --- /dev/null +++ b/watchapedia/app/collection/models.py @@ -0,0 +1,53 @@ +from sqlalchemy import Integer, String, Float, ForeignKey, Boolean +from sqlalchemy.orm import relationship, Mapped, mapped_column +from datetime import datetime +from watchapedia.database.common import Base +from sqlalchemy import DateTime +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from watchapedia.app.movie.models import Movie + from watchapedia.app.user.models import User + from watchapedia.app.collection_comment.models import CollectionComment + +class MovieCollection(Base): + __tablename__ = 'movie_collection' + + movie_id: Mapped[int] = mapped_column( + Integer, ForeignKey("movie.id", ondelete="CASCADE"), nullable=False, primary_key=True + ) + collection_id: Mapped[int] = mapped_column( + Integer, ForeignKey("collection.id", ondelete="CASCADE"), nullable=False, primary_key=True + ) + +class Collection(Base): + __tablename__ = 'collection' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + title: Mapped[str] = mapped_column(String(100), nullable=False) + overview: Mapped[str | None] = mapped_column(String(500)) + likes_count: Mapped[int] = mapped_column(Integer, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False + ) + user: Mapped["User"] = relationship("User", back_populates="collections") + + movies: Mapped[list["Movie"]] = relationship( + secondary="movie_collection", back_populates="collections" + ) + comments: Mapped[list["CollectionComment"]] = relationship( + "CollectionComment", back_populates="collection", cascade="all, delete, delete-orphan" + ) + +class UserLikesCollection(Base): + __tablename__ = 'user_likes_collection' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False + ) + collection_id: Mapped[int] = mapped_column( + Integer, ForeignKey("collection.id", ondelete="CASCADE"), nullable=False + ) + diff --git a/watchapedia/app/collection/repository.py b/watchapedia/app/collection/repository.py new file mode 100644 index 0000000..4e41ca0 --- /dev/null +++ b/watchapedia/app/collection/repository.py @@ -0,0 +1,92 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session +from fastapi import Depends +from watchapedia.database.connection import get_db_session +from typing import Annotated, Sequence +from watchapedia.app.movie.models import Movie +from watchapedia.app.collection.models import Collection, UserLikesCollection +from datetime import datetime + +class CollectionRepository(): + def __init__(self, session: Annotated[Session, Depends(get_db_session)]) -> None: + self.session = session + + def create_collection( + self, user_id: int, title: str, overview: str | None, created_at: datetime + ) -> Collection: + collection = Collection( + user_id=user_id, + title=title, + overview=overview, + likes_count=0, + created_at=created_at + ) + self.session.add(collection) + self.session.flush() + + return collection + + def add_collection_movie( + self, collection: Collection, movies: list[Movie] + ) -> None: + for movie in movies: + collection.movies.append(movie) + + self.session.flush() + + def update_collection( + self, collection: Collection, title: str | None, overview: str | None, add_movie_ids: list[int] | None, delete_movie_ids: list[int] | None + ) -> None: + if title: + collection.title = title + + if overview: + collection.overview = overview + + if delete_movie_ids: + for delete_movie_id in delete_movie_ids: + delete_movie = self.get_movie_by_movie_id(delete_movie_id) + collection.movies.remove(delete_movie) + + if add_movie_ids: + for add_movie_id in add_movie_ids: + add_movie = self.get_movie_by_movie_id(add_movie_id) + collection.movies.append(add_movie) + + self.session.flush() + + def like_collection(self, user_id: int, collection: Collection) -> Collection: + get_like_query = select(UserLikesCollection).filter( + (UserLikesCollection.user_id == user_id) + & (UserLikesCollection.collection_id == collection.id) + ) + user_likes_collection = self.session.scalar(get_like_query) + + if user_likes_collection is None: + user_likes_collection = UserLikesCollection( + user_id=user_id, + collection_id=collection.id + ) + self.session.add(user_likes_collection) + collection.likes_count += 1 + else: + self.session.delete(user_likes_collection) + collection.likes_count -= 1 + self.session.flush() + return collection + + def get_collections_by_user_id(self, user_id: int) -> Sequence[Collection]: + collections_list_query = select(Collection).where(Collection.user_id == user_id) + return self.session.scalars(collections_list_query).all() + + def get_collection_by_collection_id(self, collection_id: int) -> Collection | None: + get_collection_query = select(Collection).filter(Collection.id == collection_id) + return self.session.scalar(get_collection_query) + + def get_movie_by_movie_id(self, movie_id: int) -> Movie | None: + get_movie_query = select(Movie).filter(Movie.id==movie_id) + return self.session.scalar(get_movie_query) + + def delete_collection_by_id(self, collection: Collection) -> None: + self.session.delete(collection) + self.session.flush() diff --git a/watchapedia/app/collection/service.py b/watchapedia/app/collection/service.py new file mode 100644 index 0000000..63e00bb --- /dev/null +++ b/watchapedia/app/collection/service.py @@ -0,0 +1,111 @@ +from typing import Annotated +from fastapi import Depends +from datetime import datetime +from watchapedia.app.collection.repository import CollectionRepository +from watchapedia.app.collection.models import Collection +from watchapedia.app.user.models import User +from watchapedia.app.collection.dto.responses import CollectionResponse, MovieCompactResponse +from watchapedia.app.movie.repository import MovieRepository +from watchapedia.app.movie.errors import MovieNotFoundError +from watchapedia.app.collection.errors import * +from watchapedia.common.errors import PermissionDeniedError + +class CollectionService: + def __init__( + self, + movie_repository: Annotated[MovieRepository, Depends()], + collection_repository: Annotated[CollectionRepository, Depends()] + ) -> None: + self.movie_repository = movie_repository + self.collection_repository = collection_repository + + def create_collection( + self, user_id: int, movie_ids: list[int] | None, title: str, overview: str | None + ) -> CollectionResponse: + collection = self.collection_repository.create_collection( + user_id=user_id, title=title, overview=overview, created_at=datetime.now() + ) + if movie_ids: + movies = [] + for movie_id in movie_ids: + movie = self.movie_repository.get_movie_by_movie_id(movie_id=movie_id) + if movie is None: + raise MovieNotFoundError() + movies.append(movie) + self.collection_repository.add_collection_movie(collection=collection, movies=movies) + + return self._process_collection_response(collection) + + def update_collection( + self, collection_id: int, user_id: int, title: int | None, overview: int | None, add_movie_ids: list[int] | None, delete_movie_ids: list[int] | None + ) -> None: + if not any([title, overview, add_movie_ids, delete_movie_ids]): + raise InvalidFormatError() + collection = self.collection_repository.get_collection_by_collection_id(collection_id) + if not collection.user_id == user_id: + raise PermissionDeniedError() + + movie_id_list = self.get_movie_ids_from_collection(collection_id) + if delete_movie_ids: + for delete_movie_id in delete_movie_ids: + if delete_movie_id not in movie_id_list: + raise NoSuchMovieError() + if add_movie_ids: + for add_movie_id in add_movie_ids: + if self.movie_repository.get_movie_by_movie_id(add_movie_id) is None: + raise MovieNotFoundError() + if add_movie_id in movie_id_list: + raise MovieAlreadyExistsError() + + self.collection_repository.update_collection( + collection=collection, title=title, overview=overview, add_movie_ids=add_movie_ids, delete_movie_ids=delete_movie_ids + ) + + def get_collection_by_collection_id(self, collection_id: int) -> CollectionResponse: + collection = self.collection_repository.get_collection_by_collection_id(collection_id=collection_id) + if collection is None: + raise CollectionNotFoundError() + return self._process_collection_response(collection) + + def get_movie_ids_from_collection(self, collection_id: int) -> list[int] | None: + collection = self.collection_repository.get_collection_by_collection_id(collection_id=collection_id) + if collection is None: + raise CollectionNotFoundError() + return [ movie.id for movie in collection.movies ] + + def like_collection(self, user_id: int, collection_id: int) -> CollectionResponse: + collection = self.collection_repository.get_collection_by_collection_id(collection_id) + updated_collection = self.collection_repository.like_collection(user_id, collection) + return self._process_collection_response(updated_collection) + + def get_user_collections(self, user: User) -> list[CollectionResponse]: + collections = self.collection_repository.get_collections_by_user_id(user.id) + return [ self._process_collection_response(collection) for collection in collections ] + + def delete_collection_by_id(self, collection_id: int, user: User) -> None: + collection = self.collection_repository.get_collection_by_collection_id(collection_id) + if collection is None: + raise CollectionNotFoundError() + if collection.user_id != user.id: + raise PermissionDeniedError() + self.collection_repository.delete_collection_by_id(collection) + + def _process_collection_response(self, collection: Collection) -> CollectionResponse: + return CollectionResponse( + id=collection.id, + user_id=collection.user_id, + title=collection.title, + overview=collection.overview, + likes_count=collection.likes_count, + comments_count=len(collection.comments), + created_at=collection.created_at, + movies=[ + MovieCompactResponse( + id=movie.id, + title=movie.title, + poster_url=movie.poster_url, + average_rating=movie.average_rating + ) + for movie in collection.movies + ] + ) \ No newline at end of file diff --git a/watchapedia/app/collection/views.py b/watchapedia/app/collection/views.py new file mode 100644 index 0000000..b62aa77 --- /dev/null +++ b/watchapedia/app/collection/views.py @@ -0,0 +1,86 @@ +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse +from typing import Annotated +from datetime import datetime +from watchapedia.app.user.views import login_with_header +from watchapedia.app.user.models import User +from watchapedia.app.collection.service import CollectionService +from watchapedia.app.collection.dto.requests import CollectionCreateRequest, CollectionUpdateRequest +from watchapedia.app.collection.dto.responses import CollectionResponse + +collection_router = APIRouter() + +@collection_router.post("", + status_code=201, + summary="컬렉션 생성", + description="[로그인 필요] 컬렉션 title, overview(소개글), movie_ids를 받아 컬랙션을 생성하고 성공 시 컬렉션을 반환합니다." + ) +def create_collection( + user: Annotated[User, Depends(login_with_header)], + collection_service: Annotated[CollectionService, Depends()], + collection_request: CollectionCreateRequest, +) -> CollectionResponse: + return collection_service.create_collection( + user.id, collection_request.movie_ids, collection_request.title, collection_request.overview + ) + +@collection_router.get("/{collection_id}", + status_code=200, + summary="컬렉션 조회", + description="컬렉션 id로 조회하여 성공 시 컬렉션을 반환합니다." + ) +def search_collection( + collection_id: int, + collection_service: Annotated[CollectionService, Depends()], +) -> CollectionResponse: + return collection_service.get_collection_by_collection_id(collection_id) + +@collection_router.patch("/{collection_id}", + status_code=200, + summary="컬렉션 업데이트", + description="[로그인 필요] title, overview(소개글), 추가할 영화 id 목록, 삭제할 영화 id 목록을 받아 성공 시 'Success'을 반환합니다." + ) +def update_collection( + user: Annotated[User, Depends(login_with_header)], + collection_id: int, + collection_request: CollectionUpdateRequest, + collection_service: Annotated[CollectionService, Depends()] +): + collection_service.update_collection( + collection_id, user.id, collection_request.title, collection_request.overview, collection_request.add_movie_ids, collection_request.delete_movie_ids + ) + return "Success" + +@collection_router.patch('/like/{collection_id}', + status_code=200, + summary="컬렉션 추천/취소", + description="[로그인 필요] collection_id를 받아 추천되어 있지 않으면 추천하고, 추천되어 있으면 취소합니다.", + ) +def like_collection( + user: Annotated[User, Depends(login_with_header)], + collection_id: int, + collection_service: Annotated[CollectionService, Depends()], +) -> CollectionResponse: + return collection_service.like_collection(user.id, collection_id) + +@collection_router.get("", + status_code=200, + summary="유저 컬렉션 출력", + description="[로그인 필요] 유저가 만든 모든 컬렉션을 반환합니다.") +def get_collections_by_user( + user: Annotated[User, Depends(login_with_header)], + collection_service: Annotated[CollectionService, Depends()] +) -> list[CollectionResponse]: + return collection_service.get_user_collections(user) + +@collection_router.delete("/{collection_id}", + status_code=204, + summary="컬렉션 삭제", + description="[로그인 필요] 컬렉션 id를 받아 해당 컬렉션을 삭제합니다. 성공 시 204 code 반환") +def delete_collection( + collection_id: int, + user: Annotated[User, Depends(login_with_header)], + collection_service: Annotated[CollectionService, Depends()] +): + collection_service.delete_collection_by_id(collection_id, user) + diff --git a/watchapedia/app/collection_comment/dto/requests.py b/watchapedia/app/collection_comment/dto/requests.py new file mode 100644 index 0000000..3f9b7cf --- /dev/null +++ b/watchapedia/app/collection_comment/dto/requests.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel +from watchapedia.common.errors import InvalidFieldFormatError +from typing import Annotated +from pydantic.functional_validators import AfterValidator + +def validate_content(value: str | None) -> str | None: + # content 필드는 500자 이하여야 함 + if value is None or len(value) > 500: + raise InvalidFieldFormatError("content") + return value + +class CollectionCommentRequest(BaseModel): + content: Annotated[str, AfterValidator(validate_content)] \ No newline at end of file diff --git a/watchapedia/app/collection_comment/dto/responses.py b/watchapedia/app/collection_comment/dto/responses.py new file mode 100644 index 0000000..7922f0d --- /dev/null +++ b/watchapedia/app/collection_comment/dto/responses.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel +from datetime import datetime +from watchapedia.app.user.models import User + +class CollectionCommentResponse(BaseModel): + id: int + user_id: int + user_name: str + collection_id: int + content: str + likes_count: int + created_at: datetime + diff --git a/watchapedia/app/collection_comment/models.py b/watchapedia/app/collection_comment/models.py new file mode 100644 index 0000000..fd74bc9 --- /dev/null +++ b/watchapedia/app/collection_comment/models.py @@ -0,0 +1,38 @@ +from sqlalchemy import Integer, String, ForeignKey +from sqlalchemy.orm import relationship, Mapped, mapped_column +from datetime import datetime +from watchapedia.database.common import Base +from sqlalchemy import DateTime +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from watchapedia.app.user.models import User + from watchapedia.app.collection.models import Collection + +class CollectionComment(Base): + __tablename__ = 'collection_comment' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + content: Mapped[str] = mapped_column(String(500), nullable=False) + likes_count: Mapped[int] = mapped_column(Integer, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False + ) + user: Mapped["User"] = relationship("User", back_populates="collection_comments") + + collection_id: Mapped[int] = mapped_column( + Integer, ForeignKey("collection.id", ondelete="CASCADE"), nullable=False + ) + collection: Mapped["Collection"] = relationship("Collection", back_populates="comments") + +class UserLikesCollectionComment(Base): + __tablename__ = 'user_likes_collection_comment' + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False + ) + collection_comment_id: Mapped[int] = mapped_column( + Integer, ForeignKey("collection_comment.id", ondelete="CASCADE"), nullable=False + ) diff --git a/watchapedia/app/collection_comment/repository.py b/watchapedia/app/collection_comment/repository.py new file mode 100644 index 0000000..ff9c52a --- /dev/null +++ b/watchapedia/app/collection_comment/repository.py @@ -0,0 +1,81 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session +from fastapi import Depends +from watchapedia.database.connection import get_db_session +from typing import Annotated, Sequence +from datetime import datetime +from watchapedia.app.collection_comment.models import CollectionComment, UserLikesCollectionComment +from watchapedia.app.comment.errors import CommentNotFoundError + +class CollectionCommentRepository(): + def __init__(self, session: Annotated[Session, Depends(get_db_session)]) -> None: + self.session = session + + def get_comment_by_user_and_collection(self, user_id: int, collection_id: int) -> CollectionComment | None: + get_comment_query = select(CollectionComment).filter( + (CollectionComment.user_id == user_id) + & (CollectionComment.collection_id == collection_id) + ) + + return self.session.scalar(get_comment_query) + + def create_comment(self, user_id: int, collection_id: int, content: str, created_at) -> CollectionComment: + comment = CollectionComment( + user_id=user_id, + collection_id=collection_id, + content=content, + likes_count=0, + created_at=created_at + ) + self.session.add(comment) + self.session.flush() + + comment = self.get_comment_by_user_and_collection(user_id, collection_id) + + return comment + + def update_comment(self, comment: CollectionComment, content: str) -> CollectionComment: + comment.content = content + self.session.flush() + + return comment + + def get_comments(self, collection_id: int) -> Sequence[CollectionComment]: + comments_list_query = select(CollectionComment).where(CollectionComment.collection_id == collection_id) + return self.session.scalars(comments_list_query).all() + + def get_comment_by_comment_id(self, comment_id: int) -> CollectionComment: + comment = self.session.get(CollectionComment, comment_id) + if comment is None : + raise CommentNotFoundError() + + return comment + + def like_comment(self, user_id: int, comment: CollectionComment) -> CollectionComment: + get_like_query = select(UserLikesCollectionComment).filter( + (UserLikesCollectionComment.user_id == user_id) + & (UserLikesCollectionComment.collection_comment_id == comment.id) + ) + user_likes_comment = self.session.scalar(get_like_query) + + if user_likes_comment is None : + user_likes_comment = UserLikesCollectionComment( + user_id=user_id, + collection_comment_id=comment.id, + ) + self.session.add(user_likes_comment) + + comment.likes_count += 1 + + else : + self.session.delete(user_likes_comment) + + comment.likes_count -= 1 + + self.session.flush() + + return comment + + def delete_comment_by_id(self, comment: CollectionComment) -> None: + self.session.delete(comment) + self.session.flush() \ No newline at end of file diff --git a/watchapedia/app/collection_comment/service.py b/watchapedia/app/collection_comment/service.py new file mode 100644 index 0000000..f0c4950 --- /dev/null +++ b/watchapedia/app/collection_comment/service.py @@ -0,0 +1,72 @@ +from typing import Annotated +from fastapi import Depends +from watchapedia.common.errors import PermissionDeniedError +from watchapedia.app.collection.repository import CollectionRepository +from watchapedia.app.collection.errors import CollectionNotFoundError +from watchapedia.app.collection_comment.dto.responses import CollectionCommentResponse +from watchapedia.app.collection_comment.repository import CollectionCommentRepository +from watchapedia.app.collection_comment.models import CollectionComment +from watchapedia.app.user.models import User +from watchapedia.app.comment.errors import RedundantCommentError, CommentNotFoundError +from datetime import datetime + +class CollectionCommentService: + def __init__(self, + collection_repository: Annotated[CollectionRepository, Depends()], + collection_comment_repository: Annotated[CollectionCommentRepository, Depends()] + ) -> None: + self.collection_repository = collection_repository + self.collection_comment_repository = collection_comment_repository + + def create_comment(self, user_id: int, collection_id: int, content: str) -> CollectionCommentResponse: + collection = self.collection_repository.get_collection_by_collection_id(collection_id) + if collection is None : + raise CollectionNotFoundError() + + new_comment = self.collection_comment_repository.create_comment(user_id=user_id, collection_id=collection_id, + content=content, created_at=datetime.now()) + + return self._process_comment_response(new_comment) + + def update_comment(self, user_id: int, comment_id: int, content: str) -> CollectionCommentResponse: + comment = self.collection_comment_repository.get_comment_by_comment_id(comment_id) + if not comment.user_id == user_id : + raise PermissionDeniedError() + + updated_comment = self.collection_comment_repository.update_comment(comment, content=content) + return self._process_comment_response(updated_comment) + + def list_comments(self, collection_id: int) -> list[CollectionCommentResponse]: + collection = self.collection_repository.get_collection_by_collection_id(collection_id) + if collection is None : + raise CollectionNotFoundError() + + comments = self.collection_comment_repository.get_comments(collection_id) + return [self._process_comment_response(comment) for comment in comments] + + def like_comment(self, user_id: int, comment_id: int) -> CollectionCommentResponse : + comment = self.collection_comment_repository.get_comment_by_comment_id(comment_id) + if comment is None : + raise CommentNotFoundError() + + updated_comment = self.collection_comment_repository.like_comment(user_id, comment) + return self._process_comment_response(updated_comment) + + def delete_comment_by_id(self, comment_id: int, user: User) -> None: + comment = self.collection_comment_repository.get_comment_by_comment_id(comment_id) + if comment is None: + raise CommentNotFoundError() + if comment.user_id != user.id: + raise PermissionDeniedError() + self.collection_comment_repository.delete_comment_by_id(comment) + + def _process_comment_response(self, comment: CollectionComment) -> CollectionCommentResponse: + return CollectionCommentResponse( + id=comment.id, + user_id=comment.user.id, + user_name=comment.user.username, + collection_id=comment.collection_id, + content=comment.content, + likes_count=comment.likes_count, + created_at=comment.created_at + ) diff --git a/watchapedia/app/collection_comment/views.py b/watchapedia/app/collection_comment/views.py new file mode 100644 index 0000000..b2dcca4 --- /dev/null +++ b/watchapedia/app/collection_comment/views.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, Depends +from typing import Annotated +from datetime import datetime +from watchapedia.app.user.views import login_with_header +from watchapedia.app.user.models import User +from watchapedia.app.collection_comment.dto.requests import CollectionCommentRequest +from watchapedia.app.collection_comment.dto.responses import CollectionCommentResponse +from watchapedia.app.collection_comment.models import CollectionComment +from watchapedia.app.collection_comment.service import CollectionCommentService + +collection_comment_router = APIRouter() + +@collection_comment_router.post('/{collection_id}', + status_code=201, + summary="코멘트 작성", + description="[로그인 필요] collection_id, content를 받아 코멘트를 작성하고 성공 시 username을 포함하여 코멘트를 반환합니다.", + ) +def create_comment( + user: Annotated[User, Depends(login_with_header)], + collection_comment_service: Annotated[CollectionCommentService, Depends()], + collection_id: int, + comment: CollectionCommentRequest, +) -> CollectionCommentResponse: + commentresponse = collection_comment_service.create_comment(user.id, collection_id, comment.content) + return commentresponse + +@collection_comment_router.patch('/{comment_id}', + status_code=200, + summary="코멘트 수정", + description="[로그인 필요] comment_id와 content를 받아 코멘트를 수정하고 반환합니다.", + ) +def update_comment( + user: Annotated[User, Depends(login_with_header)], + collection_comment_service: Annotated[CollectionCommentService, Depends()], + comment_id: int, + comment: CollectionCommentRequest, +) -> CollectionCommentResponse: + return collection_comment_service.update_comment( + user.id, comment_id, comment.content + ) + +@collection_comment_router.get('/{collection_id}', + status_code=200, + summary="코멘트 출력", + description="collection_id를 받아 해당 리뷰에 달린 코멘트들을 반환합니다", + ) +def get_comments( + collection_id: int, + collection_comment_service: Annotated[CollectionCommentService, Depends()], +) -> list[CollectionCommentResponse]: + return collection_comment_service.list_comments(collection_id) + +@collection_comment_router.patch('/like/{comment_id}', + status_code=200, + summary="코멘트 추천/취소", + description="[로그인 필요] comment_id를 받아 추천되어 있지 않으면 추천하고, 추천되어 있으면 취소합니다.", + ) +def like_comment( + user: Annotated[User, Depends(login_with_header)], + comment_id: int, + collection_comment_service: Annotated[CollectionCommentService, Depends()], +) -> CollectionCommentResponse: + return collection_comment_service.like_comment(user.id, comment_id) + +@collection_comment_router.delete('/{comment_id}', + status_code=204, + summary="코멘트 삭제", + description="[로그인 필요] 코멘트 id를 받아 해당 코멘트을 삭제합니다. 성공 시 204 code 반환") +def delete_comment( + collection_id: int, + user: Annotated[User, Depends(login_with_header)], + collection_comment_service: Annotated[CollectionCommentService, Depends()] +): + collection_comment_service.delete_comment_by_id(collection_id, user) \ No newline at end of file diff --git a/watchapedia/app/movie/models.py b/watchapedia/app/movie/models.py index 5553c32..4c0c0c2 100644 --- a/watchapedia/app/movie/models.py +++ b/watchapedia/app/movie/models.py @@ -8,6 +8,7 @@ from watchapedia.app.genre.models import Genre from watchapedia.app.country.models import Country from watchapedia.app.participant.models import Participant + from watchapedia.app.collection.models import Collection class MovieParticipant(Base): __tablename__ = "movie_participant" @@ -48,6 +49,7 @@ class Movie(Base): genres: Mapped[list["Genre"]] = relationship(secondary="movie_genre", back_populates="movies") countries: Mapped[list["Country"]] = relationship(secondary="movie_country", back_populates="movies") + collections: Mapped[list["Collection"]] = relationship(secondary="movie_collection", back_populates="movies") movie_participants: Mapped[list[MovieParticipant]] = relationship("MovieParticipant", back_populates="movie") diff --git a/watchapedia/app/user/models.py b/watchapedia/app/user/models.py index 302b2d9..0232237 100644 --- a/watchapedia/app/user/models.py +++ b/watchapedia/app/user/models.py @@ -7,6 +7,7 @@ if TYPE_CHECKING: from watchapedia.app.review.models import Review from watchapedia.app.comment.models import Comment + from watchapedia.app.collection.models import Collection, CollectionComment class User(Base): __tablename__ = 'user' @@ -18,6 +19,12 @@ class User(Base): reviews: Mapped[list["Review"]] = relationship("Review", back_populates="user") comments: Mapped[list["Comment"]] = relationship("Comment", back_populates="user") + collections: Mapped[list["Collection"]] = relationship( + "Collection", back_populates="user", cascade="all, delete, delete-orphan" + ) + collection_comments: Mapped[list["CollectionComment"]] = relationship( + "CollectionComment", back_populates="user", cascade="all, delete, delete-orphan" + ) class BlockedToken(Base): __tablename__ = "blocked_token" diff --git a/watchapedia/database/__init__.py b/watchapedia/database/__init__.py index a3af340..67dcd3e 100644 --- a/watchapedia/database/__init__.py +++ b/watchapedia/database/__init__.py @@ -4,4 +4,6 @@ import watchapedia.app.country.models import watchapedia.app.participant.models import watchapedia.app.review.models -import watchapedia.app.comment.models \ No newline at end of file +import watchapedia.app.comment.models +import watchapedia.app.collection.models +import watchapedia.app.collection_comment.models \ No newline at end of file diff --git a/watchapedia/database/alembic/versions/2025_01_14_1446-e9b6097534f2_collection_etc.py b/watchapedia/database/alembic/versions/2025_01_14_1446-e9b6097534f2_collection_etc.py new file mode 100644 index 0000000..3ddb593 --- /dev/null +++ b/watchapedia/database/alembic/versions/2025_01_14_1446-e9b6097534f2_collection_etc.py @@ -0,0 +1,68 @@ +"""collection etc + +Revision ID: e9b6097534f2 +Revises: 3c1bdf2a174a +Create Date: 2025-01-14 14:46:19.567806 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'e9b6097534f2' +down_revision: Union[str, None] = '3c1bdf2a174a' +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! ### + op.create_table('collection', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=100), nullable=False), + sa.Column('overview', sa.String(length=500), nullable=True), + sa.Column('likes_count', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('collection_comment', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('content', sa.String(length=500), nullable=False), + sa.Column('likes_count', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('collection_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['collection_id'], ['collection.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('movie_collection', + sa.Column('movie_id', sa.Integer(), nullable=False), + sa.Column('collection_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['collection_id'], ['collection.id'], ), + sa.ForeignKeyConstraint(['movie_id'], ['movie.id'], ), + sa.PrimaryKeyConstraint('movie_id', 'collection_id') + ) + op.create_table('user_likes_collection_comment', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('collection_comment_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['collection_comment_id'], ['collection_comment.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('user_likes_collection_comment') + op.drop_table('movie_collection') + op.drop_table('collection_comment') + op.drop_table('collection') + # ### end Alembic commands ### diff --git a/watchapedia/database/alembic/versions/2025_01_14_1509-65dc72edb8c5_fix_collection.py b/watchapedia/database/alembic/versions/2025_01_14_1509-65dc72edb8c5_fix_collection.py new file mode 100644 index 0000000..61a617b --- /dev/null +++ b/watchapedia/database/alembic/versions/2025_01_14_1509-65dc72edb8c5_fix_collection.py @@ -0,0 +1,56 @@ +"""fix collection + +Revision ID: 65dc72edb8c5 +Revises: e9b6097534f2 +Create Date: 2025-01-14 15:09:19.113423 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '65dc72edb8c5' +down_revision: Union[str, None] = 'e9b6097534f2' +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! ### + op.drop_constraint('collection_ibfk_1', 'collection', type_='foreignkey') + op.create_foreign_key(None, 'collection', 'user', ['user_id'], ['id'], ondelete='CASCADE') + op.drop_constraint('collection_comment_ibfk_2', 'collection_comment', type_='foreignkey') + op.drop_constraint('collection_comment_ibfk_1', 'collection_comment', type_='foreignkey') + op.create_foreign_key(None, 'collection_comment', 'collection', ['collection_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key(None, 'collection_comment', 'user', ['user_id'], ['id'], ondelete='CASCADE') + op.drop_constraint('movie_collection_ibfk_2', 'movie_collection', type_='foreignkey') + op.drop_constraint('movie_collection_ibfk_1', 'movie_collection', type_='foreignkey') + op.create_foreign_key(None, 'movie_collection', 'collection', ['collection_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key(None, 'movie_collection', 'movie', ['movie_id'], ['id'], ondelete='CASCADE') + op.drop_constraint('user_likes_collection_comment_ibfk_1', 'user_likes_collection_comment', type_='foreignkey') + op.drop_constraint('user_likes_collection_comment_ibfk_2', 'user_likes_collection_comment', type_='foreignkey') + op.create_foreign_key(None, 'user_likes_collection_comment', 'collection_comment', ['collection_comment_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key(None, 'user_likes_collection_comment', 'user', ['user_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'user_likes_collection_comment', type_='foreignkey') + op.drop_constraint(None, 'user_likes_collection_comment', type_='foreignkey') + op.create_foreign_key('user_likes_collection_comment_ibfk_2', 'user_likes_collection_comment', 'user', ['user_id'], ['id']) + op.create_foreign_key('user_likes_collection_comment_ibfk_1', 'user_likes_collection_comment', 'collection_comment', ['collection_comment_id'], ['id']) + op.drop_constraint(None, 'movie_collection', type_='foreignkey') + op.drop_constraint(None, 'movie_collection', type_='foreignkey') + op.create_foreign_key('movie_collection_ibfk_1', 'movie_collection', 'collection', ['collection_id'], ['id']) + op.create_foreign_key('movie_collection_ibfk_2', 'movie_collection', 'movie', ['movie_id'], ['id']) + op.drop_constraint(None, 'collection_comment', type_='foreignkey') + op.drop_constraint(None, 'collection_comment', type_='foreignkey') + op.create_foreign_key('collection_comment_ibfk_1', 'collection_comment', 'collection', ['collection_id'], ['id']) + op.create_foreign_key('collection_comment_ibfk_2', 'collection_comment', 'user', ['user_id'], ['id']) + op.drop_constraint(None, 'collection', type_='foreignkey') + op.create_foreign_key('collection_ibfk_1', 'collection', 'user', ['user_id'], ['id']) + # ### end Alembic commands ### diff --git a/watchapedia/database/alembic/versions/2025_01_14_2035-ac577377af66_add_userlikescollection.py b/watchapedia/database/alembic/versions/2025_01_14_2035-ac577377af66_add_userlikescollection.py new file mode 100644 index 0000000..cf8a2ae --- /dev/null +++ b/watchapedia/database/alembic/versions/2025_01_14_2035-ac577377af66_add_userlikescollection.py @@ -0,0 +1,37 @@ +"""add UserLikesCollection + +Revision ID: ac577377af66 +Revises: 65dc72edb8c5 +Create Date: 2025-01-14 20:35:57.156638 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ac577377af66' +down_revision: Union[str, None] = '65dc72edb8c5' +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! ### + op.create_table('user_likes_collection', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('collection_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['collection_id'], ['collection.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('user_likes_collection') + # ### end Alembic commands ### From 143765fe73eccf0cd7a1685c7317644373482a76 Mon Sep 17 00:00:00 2001 From: deveroskp Date: Thu, 16 Jan 2025 17:29:29 +0900 Subject: [PATCH 15/26] =?UTF-8?q?Fix:=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/collection/service.py | 2 ++ watchapedia/app/collection_comment/views.py | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/watchapedia/app/collection/service.py b/watchapedia/app/collection/service.py index 63e00bb..1c1ca94 100644 --- a/watchapedia/app/collection/service.py +++ b/watchapedia/app/collection/service.py @@ -75,6 +75,8 @@ def get_movie_ids_from_collection(self, collection_id: int) -> list[int] | None: def like_collection(self, user_id: int, collection_id: int) -> CollectionResponse: collection = self.collection_repository.get_collection_by_collection_id(collection_id) + if collection is None: + raise CollectionNotFoundError() updated_collection = self.collection_repository.like_collection(user_id, collection) return self._process_collection_response(updated_collection) diff --git a/watchapedia/app/collection_comment/views.py b/watchapedia/app/collection_comment/views.py index b2dcca4..faafa0b 100644 --- a/watchapedia/app/collection_comment/views.py +++ b/watchapedia/app/collection_comment/views.py @@ -67,8 +67,8 @@ def like_comment( summary="코멘트 삭제", description="[로그인 필요] 코멘트 id를 받아 해당 코멘트을 삭제합니다. 성공 시 204 code 반환") def delete_comment( - collection_id: int, + comment_id: int, user: Annotated[User, Depends(login_with_header)], collection_comment_service: Annotated[CollectionCommentService, Depends()] ): - collection_comment_service.delete_comment_by_id(collection_id, user) \ No newline at end of file + collection_comment_service.delete_comment_by_id(comment_id, user) \ No newline at end of file From 24462b11aaf7b3d83781da9982bfa56777b1815d Mon Sep 17 00:00:00 2001 From: deveroskp Date: Thu, 16 Jan 2025 17:42:38 +0900 Subject: [PATCH 16/26] =?UTF-8?q?Fix:=20=ED=8C=94=EB=A1=9C=EC=9E=89,=20?= =?UTF-8?q?=ED=8C=94=EB=A1=9C=EC=9B=8C=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=9D=B8=EC=A6=9D=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/user/views.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/watchapedia/app/user/views.py b/watchapedia/app/user/views.py index 6ec81aa..1f0c1fc 100644 --- a/watchapedia/app/user/views.py +++ b/watchapedia/app/user/views.py @@ -111,19 +111,19 @@ def unfollow( user_service.unfollow(user.id, follow_user_id) return "Success" -@user_router.get('/followings', status_code=200, summary="팔로잉 목록", description="access_token을 헤더에 담아 요청하면 내가 팔로잉하는 유저들의 목록을 반환합니다.") +@user_router.get('/followings/{user_id}', status_code=200, summary="팔로잉 목록", description="user_id를 받아 해당 유저가 팔로우하는 유저들의 목록을 반환합니다.") def followings( - user: Annotated[User, Depends(login_with_header)], + user_id: int, user_service: Annotated[UserService, Depends()], ) -> list[MyProfileResponse]: - return user_service.get_followings(user.id) + return user_service.get_followings(user_id) -@user_router.get('/followers', status_code=200, summary="팔로워 목록", description="access_token을 헤더에 담아 요청하면 나를 팔로우하는 유저들의 목록을 반환합니다.") +@user_router.get('/followers/{user_id}', status_code=200, summary="팔로워 목록", description="user_id를 받아 해당 유저를 팔로우하는 유저들의 목록을 반환합니다.") def followers( - user: Annotated[User, Depends(login_with_header)], + user_id: int, user_service: Annotated[UserService, Depends()], ): - return user_service.get_followers(user.id) + return user_service.get_followers(user_id) @user_router.get('/profile/{user_id}', status_code=200, summary="프로필 조회", description="user_id를 받아 해당 유저의 프로필 정보를 반환합니다.") def profile( From 97dc42e3651635fb01527a976002dce784b18226 Mon Sep 17 00:00:00 2001 From: deveroskp Date: Fri, 17 Jan 2025 00:52:48 +0900 Subject: [PATCH 17/26] =?UTF-8?q?Feat:=20=EC=83=81=ED=83=9C=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=EB=82=98=20=EC=BD=94=EB=A9=98=ED=8A=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=8B=9C=20=ED=94=84=EB=A1=9C=ED=95=84=20url?= =?UTF-8?q?=20=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/collection_comment/dto/responses.py | 1 + watchapedia/app/collection_comment/service.py | 1 + watchapedia/app/comment/dto/responses.py | 1 + watchapedia/app/comment/service.py | 1 + watchapedia/app/review/dto/responses.py | 1 + watchapedia/app/review/service.py | 1 + watchapedia/app/user/dto/requests.py | 11 ++++++- watchapedia/app/user/dto/responses.py | 2 ++ watchapedia/app/user/models.py | 1 + watchapedia/app/user/repository.py | 6 +++- watchapedia/app/user/service.py | 8 +++-- watchapedia/app/user/views.py | 4 +-- ...-d6a643d1533a_add_status_message_column.py | 30 +++++++++++++++++++ 13 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 watchapedia/database/alembic/versions/2025_01_17_0036-d6a643d1533a_add_status_message_column.py diff --git a/watchapedia/app/collection_comment/dto/responses.py b/watchapedia/app/collection_comment/dto/responses.py index 7922f0d..e4e42bb 100644 --- a/watchapedia/app/collection_comment/dto/responses.py +++ b/watchapedia/app/collection_comment/dto/responses.py @@ -6,6 +6,7 @@ class CollectionCommentResponse(BaseModel): id: int user_id: int user_name: str + profile_url: str | None collection_id: int content: str likes_count: int diff --git a/watchapedia/app/collection_comment/service.py b/watchapedia/app/collection_comment/service.py index f0c4950..cb1a89c 100644 --- a/watchapedia/app/collection_comment/service.py +++ b/watchapedia/app/collection_comment/service.py @@ -65,6 +65,7 @@ def _process_comment_response(self, comment: CollectionComment) -> CollectionCom id=comment.id, user_id=comment.user.id, user_name=comment.user.username, + profile_url=comment.user.profile_url, collection_id=comment.collection_id, content=comment.content, likes_count=comment.likes_count, diff --git a/watchapedia/app/comment/dto/responses.py b/watchapedia/app/comment/dto/responses.py index d9db903..5bf2a42 100644 --- a/watchapedia/app/comment/dto/responses.py +++ b/watchapedia/app/comment/dto/responses.py @@ -7,6 +7,7 @@ class CommentResponse(BaseModel): id: int user_id: int user_name: str + profile_url: str | None review_id: int content: str likes_count: int diff --git a/watchapedia/app/comment/service.py b/watchapedia/app/comment/service.py index 8a4d127..e0d10d6 100644 --- a/watchapedia/app/comment/service.py +++ b/watchapedia/app/comment/service.py @@ -56,6 +56,7 @@ def _process_comment_response(self, comment: Comment) -> CommentResponse: id=comment.id, user_id=comment.user.id, user_name=comment.user.username, + profile_url=comment.user.profile_url, review_id=comment.review_id, content=comment.content, likes_count=comment.likes_count, diff --git a/watchapedia/app/review/dto/responses.py b/watchapedia/app/review/dto/responses.py index e00cda0..fd11ad8 100644 --- a/watchapedia/app/review/dto/responses.py +++ b/watchapedia/app/review/dto/responses.py @@ -7,6 +7,7 @@ class ReviewResponse(BaseModel): id: int user_id: int user_name: str + profile_url: str | None movie_id: int content: str | None rating: float | None diff --git a/watchapedia/app/review/service.py b/watchapedia/app/review/service.py index 7733ff2..d4c68bf 100644 --- a/watchapedia/app/review/service.py +++ b/watchapedia/app/review/service.py @@ -72,6 +72,7 @@ def _process_review_response(self, review: Review) -> ReviewResponse: id=review.id, user_id=review.user.id, user_name=review.user.username, + profile_url=review.user.profile_url, movie_id=review.movie_id, content=review.content, rating=review.rating, diff --git a/watchapedia/app/user/dto/requests.py b/watchapedia/app/user/dto/requests.py index dc3cf04..09c9504 100644 --- a/watchapedia/app/user/dto/requests.py +++ b/watchapedia/app/user/dto/requests.py @@ -54,6 +54,14 @@ def validate_password(value: str | None) -> str | None: return value +def validate_status_message(value: str | None) -> str | None: + # status_message 필드는 100자 이하의 문자열 + if value is None: + return value + if len(value) > 100: + raise InvalidFieldFormatError("status_message") + return value + class UserSignupRequest(BaseModel): username: Annotated[str, AfterValidator(validate_username)] login_id: Annotated[str, AfterValidator(validate_login_id)] @@ -66,4 +74,5 @@ class UserSigninRequest(BaseModel): class UserUpdateRequest(BaseModel): username: Annotated[str | None, AfterValidator(validate_username)] = None login_password: Annotated[str | None, AfterValidator(validate_password)] = None - profile_url: Annotated[str | None, AfterValidator(validate_url)] = None \ No newline at end of file + profile_url: Annotated[str | None, AfterValidator(validate_url)] = None + status_message: Annotated[str | None ,AfterValidator(validate_status_message)] = None \ No newline at end of file diff --git a/watchapedia/app/user/dto/responses.py b/watchapedia/app/user/dto/responses.py index ddcf8fc..16bbbde 100644 --- a/watchapedia/app/user/dto/responses.py +++ b/watchapedia/app/user/dto/responses.py @@ -16,6 +16,7 @@ class UserProfileResponse(BaseModel): username: str login_id: str profile_url: str | None = None + status_message: str | None = None following_count: int | None = None follower_count: int | None = None review_count: int | None = None @@ -28,6 +29,7 @@ def from_user(user, following_count, follower_count, review_count, comment_count username=user.username, login_id=user.login_id, profile_url=user.profile_url, + status_message=user.status_message, following_count=following_count, follower_count=follower_count, review_count=review_count, diff --git a/watchapedia/app/user/models.py b/watchapedia/app/user/models.py index c3eadab..71de464 100644 --- a/watchapedia/app/user/models.py +++ b/watchapedia/app/user/models.py @@ -17,6 +17,7 @@ class User(Base): login_id: Mapped[str] = mapped_column(String(50), nullable=False) hashed_pwd: Mapped[str] = mapped_column(String(100), nullable=False) profile_url: Mapped[str | None] = mapped_column(String(500), nullable=True) + status_message: Mapped[str | None] = mapped_column(String(100), nullable=True) reviews: Mapped[list["Review"]] = relationship("Review", back_populates="user") comments: Mapped[list["Comment"]] = relationship("Comment", back_populates="user") diff --git a/watchapedia/app/user/repository.py b/watchapedia/app/user/repository.py index 6a8d11b..b077f3e 100644 --- a/watchapedia/app/user/repository.py +++ b/watchapedia/app/user/repository.py @@ -34,7 +34,7 @@ def get_user(self, username: str, login_id: str) -> User | None: return self.session.scalar(get_user_query) - def update_user(self, user_id:int, username: str | None, login_password: str | None) -> None: + def update_user(self, user_id:int, username: str | None, login_password: str | None, profile_url: str | None, status_message: str | None) -> None: user = self.get_user_by_user_id(user_id) if username is not None: if self.get_user_by_username(username) is not None: @@ -42,6 +42,10 @@ def update_user(self, user_id:int, username: str | None, login_password: str | N user.username = username if login_password is not None: user.hashed_pwd = create_hashed_password(login_password) + if profile_url is not None: + user.profile_url = profile_url + if status_message is not None: + user.status_message = status_message def follow(self, follower_id: int, following_id: int) -> None: follow = Follow(follower_id=follower_id, following_id=following_id) diff --git a/watchapedia/app/user/service.py b/watchapedia/app/user/service.py index 3a8ffe5..42a946e 100644 --- a/watchapedia/app/user/service.py +++ b/watchapedia/app/user/service.py @@ -45,10 +45,14 @@ def unfollow(self, follower_id: int, following_id: int) -> None: self.user_repository.unfollow(follower_id, following_id) def get_followings(self, user_id: int) -> list[MyProfileResponse]: + if self.get_user_by_user_id(user_id) is None: + raise UserNotFoundError() users= self.user_repository.get_followings(user_id) return [self._process_user_response(user) for user in users] def get_followers(self, user_id: int) -> list[MyProfileResponse]: + if self.get_user_by_user_id(user_id) is None: + raise UserNotFoundError() users = self.user_repository.get_followers(user_id) return [self._process_user_response(user) for user in users] @@ -67,8 +71,8 @@ def get_comments_count(self, user_id: int) -> int: def get_collections_count(self, user_id: int) -> int: return self.user_repository.get_collections_count(user_id) - def update_user(self, user_id:int, username: str | None, login_password: str | None) -> None: - self.user_repository.update_user(user_id, username, login_password) + def update_user(self, user_id:int, username: str | None, login_password: str | None, profile_url: str | None, status_message: str | None) -> None: + self.user_repository.update_user(user_id, username, login_password, profile_url, status_message) def raise_if_user_exists(self, username: str, login_id: str) -> None: if self.user_repository.get_user(username, login_id) is not None: diff --git a/watchapedia/app/user/views.py b/watchapedia/app/user/views.py index 1f0c1fc..9f02273 100644 --- a/watchapedia/app/user/views.py +++ b/watchapedia/app/user/views.py @@ -76,7 +76,7 @@ def signin( @user_router.get('/me', status_code=200, summary="내 정보", - description="access_token을 헤더에 담아 요청하면 내 정보를 반환합니다.", + description="access_token을 헤더에 담아 요청하면 내 정보(유저 이름, 로그인 id, 프로필 url)를 반환합니다.", response_model=MyProfileResponse ) def me( @@ -90,7 +90,7 @@ def update_me( update_request: UserUpdateRequest, user_service: Annotated[UserService, Depends()], ): - user_service.update_user(user.id, username= update_request.username, login_password=update_request.login_password) + user_service.update_user(user.id, username= update_request.username, login_password=update_request.login_password, profile_url=update_request.profile_url, status_message=update_request.status_message) return "Success" @user_router.post('/follow/{follow_user_id}', status_code=201, summary="팔로우", description="user_id를 받아 해당 유저를 팔로우하고 성공 시 'Success'를 반환합니다.") diff --git a/watchapedia/database/alembic/versions/2025_01_17_0036-d6a643d1533a_add_status_message_column.py b/watchapedia/database/alembic/versions/2025_01_17_0036-d6a643d1533a_add_status_message_column.py new file mode 100644 index 0000000..0b2faa4 --- /dev/null +++ b/watchapedia/database/alembic/versions/2025_01_17_0036-d6a643d1533a_add_status_message_column.py @@ -0,0 +1,30 @@ +"""add status_message column + +Revision ID: d6a643d1533a +Revises: f11b9b212861 +Create Date: 2025-01-17 00:36:59.026038 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd6a643d1533a' +down_revision: Union[str, None] = 'f11b9b212861' +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! ### + op.add_column('user', sa.Column('status_message', sa.String(length=100), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'status_message') + # ### end Alembic commands ### From 601964def6f1fa482968aa53dd9d1da1a034dfeb Mon Sep 17 00:00:00 2001 From: Lee Hoseok Date: Sat, 18 Jan 2025 06:26:05 +0900 Subject: [PATCH 18/26] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/api.py | 4 ++- watchapedia/app/collection/repository.py | 5 +++ watchapedia/app/collection/service.py | 6 +++- watchapedia/app/movie/service.py | 2 +- watchapedia/app/participant/repository.py | 9 +++-- watchapedia/app/participant/service.py | 11 +++++- watchapedia/app/search/dto/requests.py | 12 +++++++ watchapedia/app/search/dto/responses.py | 12 +++++++ watchapedia/app/search/service.py | 41 +++++++++++++++++++++++ watchapedia/app/search/views.py | 16 +++++++++ watchapedia/app/user/dto/responses.py | 6 +++- watchapedia/app/user/repository.py | 7 +++- watchapedia/app/user/service.py | 9 +++-- 13 files changed, 130 insertions(+), 10 deletions(-) create mode 100644 watchapedia/app/search/dto/requests.py create mode 100644 watchapedia/app/search/dto/responses.py create mode 100644 watchapedia/app/search/service.py create mode 100644 watchapedia/app/search/views.py diff --git a/watchapedia/api.py b/watchapedia/api.py index b03533e..653de98 100644 --- a/watchapedia/api.py +++ b/watchapedia/api.py @@ -6,6 +6,7 @@ from watchapedia.app.participant.views import participant_router from watchapedia.app.collection.views import collection_router from watchapedia.app.collection_comment.views import collection_comment_router +from watchapedia.app.search.views import search_router api_router = APIRouter() @@ -15,4 +16,5 @@ api_router.include_router(comment_router, prefix='/comments', tags=['comments']) api_router.include_router(participant_router, prefix='/participants', tags=['participants']) api_router.include_router(collection_router, prefix='/collections', tags=['collections']) -api_router.include_router(collection_comment_router, prefix='/collection_comments', tags=['collection_comments']) \ No newline at end of file +api_router.include_router(collection_comment_router, prefix='/collection_comments', tags=['collection_comments']) +api_router.include_router(search_router, prefix='/search', tags=['search']) diff --git a/watchapedia/app/collection/repository.py b/watchapedia/app/collection/repository.py index 4e41ca0..00c6f4c 100644 --- a/watchapedia/app/collection/repository.py +++ b/watchapedia/app/collection/repository.py @@ -83,6 +83,11 @@ def get_collection_by_collection_id(self, collection_id: int) -> Collection | No get_collection_query = select(Collection).filter(Collection.id == collection_id) return self.session.scalar(get_collection_query) + # title으로 복수의 collection get. 부분집합 허용. + def search_collection_list(self, title: str) -> list[Collection] | None: + get_collection_query = select(Collection).filter(Collection.title.ilike(f"%{title}%")) + return self.session.execute(get_collection_query).scalars().all() + def get_movie_by_movie_id(self, movie_id: int) -> Movie | None: get_movie_query = select(Movie).filter(Movie.id==movie_id) return self.session.scalar(get_movie_query) diff --git a/watchapedia/app/collection/service.py b/watchapedia/app/collection/service.py index 1c1ca94..94842ee 100644 --- a/watchapedia/app/collection/service.py +++ b/watchapedia/app/collection/service.py @@ -84,6 +84,10 @@ def get_user_collections(self, user: User) -> list[CollectionResponse]: collections = self.collection_repository.get_collections_by_user_id(user.id) return [ self._process_collection_response(collection) for collection in collections ] + def search_collection_list(self, title: str) -> list[CollectionResponse] | None: + collections = self.collection_repository.search_collection_list(title) + return [ self._process_collection_response(collection) for collection in collections ] + def delete_collection_by_id(self, collection_id: int, user: User) -> None: collection = self.collection_repository.get_collection_by_collection_id(collection_id) if collection is None: @@ -110,4 +114,4 @@ def _process_collection_response(self, collection: Collection) -> CollectionResp ) for movie in collection.movies ] - ) \ No newline at end of file + ) diff --git a/watchapedia/app/movie/service.py b/watchapedia/app/movie/service.py index 8004c75..3a03688 100644 --- a/watchapedia/app/movie/service.py +++ b/watchapedia/app/movie/service.py @@ -192,4 +192,4 @@ def _process_movie_response(self, movie: Movie) -> MovieDataResponse: ] ) - \ No newline at end of file + diff --git a/watchapedia/app/participant/repository.py b/watchapedia/app/participant/repository.py index ff7a364..cc82d87 100644 --- a/watchapedia/app/participant/repository.py +++ b/watchapedia/app/participant/repository.py @@ -41,7 +41,12 @@ def get_participant(self, name: str, profile_url: str | None) -> Participant | N & (Participant.name == name) ) return self.session.scalar(get_participant_query) - + + # name으로 복수의 participant get. 부분집합 허용. + def search_participant_list(self, name: str) -> list[Participant] | None: + get_participant_query = select(Participant).filter(Participant.name.ilike(f"%{name}%")) + return self.session.execute(get_participant_query).scalars().all() + def get_participant_by_id(self, participant_id: int) -> Participant | None: get_participant_query = select(Participant).filter(Participant.id == participant_id) return self.session.scalar(get_participant_query) @@ -74,4 +79,4 @@ def get_participant_movies(self, participant_id: int, cast: str) -> list[Movie]: (MovieParticipant.participant_id == participant_id) & (MovieParticipant.role.contains(cast)) ) - return self.session.scalars(get_participant_movies_query).all() \ No newline at end of file + return self.session.scalars(get_participant_movies_query).all() diff --git a/watchapedia/app/participant/service.py b/watchapedia/app/participant/service.py index 164a6bf..008482c 100644 --- a/watchapedia/app/participant/service.py +++ b/watchapedia/app/participant/service.py @@ -59,6 +59,15 @@ def update_participant(self, participant_id: int, name: str | None, profile_url: def get_participant_roles(self, participant_id: int): return self.participant_repository.get_participant_roles(participant_id) + + def search_participant_list(self, name: str) -> list[ParticipantProfileResponse] | None: + participants = self.participant_repository.search_participant_list(name) + return [ParticipantProfileResponse( + id=participant.id, + name=participant.name, + profile_url=None, + roles=['temp'], + biography=None) for participant in participants] def _process_movies(self, movies: list[Movie], cast: str) -> list[MovieDataResponse]: return [MovieDataResponse(id=movie.id, @@ -70,4 +79,4 @@ def _process_movies(self, movies: list[Movie], cast: str) -> list[MovieDataRespo for movie in movies] def _process_participants(self, role: str, movies: list[MovieDataResponse]) -> ParticipantDataResponse: - return ParticipantDataResponse(role=role, movies=movies) \ No newline at end of file + return ParticipantDataResponse(role=role, movies=movies) diff --git a/watchapedia/app/search/dto/requests.py b/watchapedia/app/search/dto/requests.py new file mode 100644 index 0000000..fbc7225 --- /dev/null +++ b/watchapedia/app/search/dto/requests.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel +from watchapedia.common.errors import InvalidFieldFormatError +from typing import Annotated +from pydantic.functional_validators import AfterValidator + +def validate_search_query(value: str | None) -> str: + if len(value) > 100: + raise InvalidFieldFormatError("search query") + return value + +class SearchRequest(BaseModel): + search_query: Annotated[str | None, AfterValidator(validate_search_query)] = None diff --git a/watchapedia/app/search/dto/responses.py b/watchapedia/app/search/dto/responses.py new file mode 100644 index 0000000..9a6acd5 --- /dev/null +++ b/watchapedia/app/search/dto/responses.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel +# from watchapedia.app.movie.dto.responses import MovieDataResponse +from watchapedia.app.user.dto.responses import UserResponse + +class SearchResponse(BaseModel): + # movie_list: list[MovieDataResponse] + #user_list: list[UserResponse] + movie_list: list[int] | None + user_list: list[int] | None + participant_list: list[int] | None + collection_list: list[int] | None + diff --git a/watchapedia/app/search/service.py b/watchapedia/app/search/service.py new file mode 100644 index 0000000..f17bb02 --- /dev/null +++ b/watchapedia/app/search/service.py @@ -0,0 +1,41 @@ +from typing import Annotated +from fastapi import Depends +from watchapedia.app.search.dto.responses import SearchResponse +from watchapedia.app.movie.service import MovieService +from watchapedia.app.user.service import UserService +from watchapedia.app.participant.service import ParticipantService +from watchapedia.app.collection.service import CollectionService + +class SearchService(): + def __init__(self, + movie_service: Annotated[MovieService, Depends()], + user_service: Annotated[UserService, Depends()], + participant_service: Annotated[ParticipantService, Depends()], + collection_service: Annotated[CollectionService, Depends()] + ) -> None: + self.movie_service = movie_service + self.user_service = user_service + self.participant_service = participant_service + self.collection_service = collection_service + + def search(self, + name: str + ) -> None: + self.movie_list = self.movie_service.search_movie_list(name) + self.user_list = self.user_service.search_user_list(name) + self.participant_list = self.participant_service.search_participant_list(name) + self.collection_list = self.collection_service.search_collection_list(name) + + def process_search_response(self) -> SearchResponse: + movie_list = [i.id for i in self.movie_list] + user_list = [i.id for i in self.user_list] + participant_list = [i.id for i in self.participant_list] + collection_list = [i.id for i in self.collection_list] + + return SearchResponse( + movie_list=movie_list, + user_list=user_list, + participant_list=participant_list, + collection_list=collection_list + ) + diff --git a/watchapedia/app/search/views.py b/watchapedia/app/search/views.py new file mode 100644 index 0000000..f05f1f6 --- /dev/null +++ b/watchapedia/app/search/views.py @@ -0,0 +1,16 @@ +from typing import Annotated +from fastapi import APIRouter, Depends + +from watchapedia.app.search.service import SearchService +from watchapedia.app.search.dto.requests import SearchRequest +from watchapedia.app.search.dto.responses import SearchResponse + +search_router = APIRouter() + +@search_router.get("/", status_code=200, summary="검색", description="검색어와 일치하는 movie, user, participant, collection id 반환") +def search(search_request: SearchRequest, + search_service: Annotated[SearchService, Depends()] + ) -> SearchResponse: + search_q = search_request.search_query + search_service.search(search_q) + return search_service.process_search_response() diff --git a/watchapedia/app/user/dto/responses.py b/watchapedia/app/user/dto/responses.py index 16bbbde..a53a3b4 100644 --- a/watchapedia/app/user/dto/responses.py +++ b/watchapedia/app/user/dto/responses.py @@ -38,4 +38,8 @@ def from_user(user, following_count, follower_count, review_count, comment_count ) class UserSigninResponse(BaseModel): access_token: str - refresh_token: str \ No newline at end of file + refresh_token: str + + +class UserResponse(BaseModel): + id: int diff --git a/watchapedia/app/user/repository.py b/watchapedia/app/user/repository.py index b077f3e..2c7ce1a 100644 --- a/watchapedia/app/user/repository.py +++ b/watchapedia/app/user/repository.py @@ -98,6 +98,11 @@ def get_user_by_username(self, username: str) -> User | None: get_user_query = select(User).filter(User.username == username) return self.session.scalar(get_user_query) + # username으로 복수의 user get. 부분집합 허용. + def search_user_list(self, username: str) -> list[User] | None: + get_user_query = select(User).filter(User.username.ilike(f"%{username}%")) + return self.session.execute(get_user_query).scalars().all() + def block_token(self, token_id: str, expired_at: datetime) -> None: blocked_token = BlockedToken(token_id=token_id, expired_at=expired_at) self.session.add(blocked_token) @@ -115,4 +120,4 @@ def is_following(self, follower_id: int, following_id: int) -> bool: follow_query = select(Follow).filter( (Follow.follower_id == follower_id) & (Follow.following_id == following_id) ) - return self.session.scalar(follow_query) is not None \ No newline at end of file + return self.session.scalar(follow_query) is not None diff --git a/watchapedia/app/user/service.py b/watchapedia/app/user/service.py index 42a946e..ecdda3b 100644 --- a/watchapedia/app/user/service.py +++ b/watchapedia/app/user/service.py @@ -3,7 +3,7 @@ from fastapi import Depends from watchapedia.common.errors import InvalidCredentialsError, InvalidTokenError, BlockedTokenError from watchapedia.app.user.errors import UserAlreadyExistsError, UserNotFoundError, UserAlreadyFollowingError, UserAlreadyNotFollowingError, CANNOT_FOLLOW_MYSELF_Error -from watchapedia.app.user.dto.responses import MyProfileResponse +from watchapedia.app.user.dto.responses import MyProfileResponse, UserResponse from watchapedia.auth.utils import verify_password from watchapedia.app.user.models import User from watchapedia.auth.utils import create_access_token, create_refresh_token, decode_token @@ -87,6 +87,11 @@ def get_user_by_user_id(self, user_id: int) -> User | None: def get_user_by_username(self, username: str) -> User | None: return self.user_repository.get_user_by_username(username) + def search_user_list(self, username: str) -> list[UserResponse] | None: + users = self.user_repository.search_user_list(username) + return [UserResponse( + id=user.id) for user in users] + def validate_access_token(self, token: str) -> dict: payload = decode_token(token) if payload['typ'] != 'access': @@ -119,4 +124,4 @@ def _process_user_response(self, user: User) -> MyProfileResponse: username=user.username, login_id=user.login_id, profile_url=user.profile_url - ) \ No newline at end of file + ) From 57dc5a9e153e2f0003c1d7ba69e6d045cf3fdde4 Mon Sep 17 00:00:00 2001 From: Lee Hoseok Date: Sun, 19 Jan 2025 20:02:00 +0900 Subject: [PATCH 19/26] =?UTF-8?q?Fix:=20=EA=B2=80=EC=83=89=EC=96=B4=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=ED=98=95=ED=83=9C=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/search/dto/requests.py | 10 ++++------ watchapedia/app/search/views.py | 11 ++++++----- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/watchapedia/app/search/dto/requests.py b/watchapedia/app/search/dto/requests.py index fbc7225..89594cd 100644 --- a/watchapedia/app/search/dto/requests.py +++ b/watchapedia/app/search/dto/requests.py @@ -2,11 +2,9 @@ from watchapedia.common.errors import InvalidFieldFormatError from typing import Annotated from pydantic.functional_validators import AfterValidator +from fastapi import Query -def validate_search_query(value: str | None) -> str: - if len(value) > 100: +def validate_search_query(search_q: str = Query(...)) -> str: + if len(search_q) > 100: raise InvalidFieldFormatError("search query") - return value - -class SearchRequest(BaseModel): - search_query: Annotated[str | None, AfterValidator(validate_search_query)] = None + return search_q diff --git a/watchapedia/app/search/views.py b/watchapedia/app/search/views.py index f05f1f6..ac171d1 100644 --- a/watchapedia/app/search/views.py +++ b/watchapedia/app/search/views.py @@ -2,15 +2,16 @@ from fastapi import APIRouter, Depends from watchapedia.app.search.service import SearchService -from watchapedia.app.search.dto.requests import SearchRequest from watchapedia.app.search.dto.responses import SearchResponse +from watchapedia.app.search.dto.requests import validate_search_query search_router = APIRouter() -@search_router.get("/", status_code=200, summary="검색", description="검색어와 일치하는 movie, user, participant, collection id 반환") -def search(search_request: SearchRequest, - search_service: Annotated[SearchService, Depends()] +@search_router.get("", status_code=200, summary="검색", description="검색어와 일치하는 movie, user, participant, collection id 반환") +def search( + search_service: Annotated[SearchService, Depends()], + search_q: str = Depends(validate_search_query) ) -> SearchResponse: - search_q = search_request.search_query search_service.search(search_q) return search_service.process_search_response() + From e8fc482a5fca44afe0ca41ee8e08c38e1d4a5cd6 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 19 Jan 2025 13:05:42 +0000 Subject: [PATCH 20/26] =?UTF-8?q?Review=20/=20Comment=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.local:Zone.Identifier | 0 watchapedia/app/comment/dto/responses.py | 1 + watchapedia/app/comment/repository.py | 24 +++++++++- watchapedia/app/comment/service.py | 40 ++++++++++++---- watchapedia/app/comment/views.py | 46 +++++++++++++++++-- watchapedia/app/review/dto/responses.py | 1 + watchapedia/app/review/repository.py | 16 +++++++ watchapedia/app/review/service.py | 38 +++++++++++---- watchapedia/app/review/views.py | 36 ++++++++++++--- .../versions/2025_01_19_1247-199025363746_.py | 30 ++++++++++++ .../versions/2025_01_19_1247-28d5dee178b9_.py | 30 ++++++++++++ 11 files changed, 232 insertions(+), 30 deletions(-) create mode 100644 .env.local:Zone.Identifier create mode 100644 watchapedia/database/alembic/versions/2025_01_19_1247-199025363746_.py create mode 100644 watchapedia/database/alembic/versions/2025_01_19_1247-28d5dee178b9_.py diff --git a/.env.local:Zone.Identifier b/.env.local:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/watchapedia/app/comment/dto/responses.py b/watchapedia/app/comment/dto/responses.py index 5bf2a42..88150b8 100644 --- a/watchapedia/app/comment/dto/responses.py +++ b/watchapedia/app/comment/dto/responses.py @@ -12,4 +12,5 @@ class CommentResponse(BaseModel): content: str likes_count: int created_at: datetime + like: bool diff --git a/watchapedia/app/comment/repository.py b/watchapedia/app/comment/repository.py index 11f11cb..572f6dc 100644 --- a/watchapedia/app/comment/repository.py +++ b/watchapedia/app/comment/repository.py @@ -40,10 +40,14 @@ def update_comment(self, comment, content: str) -> Comment: return comment - def get_comments(self, review_id: int) -> Sequence[Comment]: + def get_comments_by_review_id(self, review_id: int) -> Sequence[Comment]: comments_list_query = select(Comment).where(Comment.review_id == review_id) return self.session.scalars(comments_list_query).all() + def get_comments_by_user_id(self, user_id: int) -> Sequence[Comment]: + comments_list_query = select(Comment).where(Comment.user_id == user_id) + return self.session.scalars(comments_list_query).all() + def get_comment_by_comment_id(self, comment_id: int) -> Comment: comment = self.session.get(Comment, comment_id) if comment is None : @@ -51,6 +55,18 @@ def get_comment_by_comment_id(self, comment_id: int) -> Comment: return comment + def like_info(self, user_id: int, comment: Comment) -> bool : + get_like_query = select(UserLikesComment).filter( + (UserLikesComment.user_id == user_id) + & (UserLikesComment.comment_id == comment.id) + ) + user_likes_comment = self.session.scalar(get_like_query) + + if user_likes_comment is None : + return False + else : + return True + def like_comment(self, user_id: int, comment: Comment) -> Comment: get_like_query = select(UserLikesComment).filter( (UserLikesComment.user_id == user_id) @@ -74,4 +90,8 @@ def like_comment(self, user_id: int, comment: Comment) -> Comment: self.session.flush() - return comment \ No newline at end of file + return comment + + def delete_comment_by_id(self, comment: Comment) -> None: + self.session.delete(comment) + self.session.flush() \ No newline at end of file diff --git a/watchapedia/app/comment/service.py b/watchapedia/app/comment/service.py index e0d10d6..995cc7a 100644 --- a/watchapedia/app/comment/service.py +++ b/watchapedia/app/comment/service.py @@ -25,7 +25,7 @@ def create_comment(self, user_id: int, review_id: int, content: str) -> CommentR new_comment = self.comment_repository.create_comment(user_id=user_id, review_id=review_id, content=content, created_at=datetime.now()) - return self._process_comment_response(new_comment) + return self._process_comment_response(user_id, new_comment) def update_comment(self, user_id: int, comment_id: int, content: str) -> CommentResponse: comment = self.comment_repository.get_comment_by_comment_id(comment_id) @@ -33,15 +33,29 @@ def update_comment(self, user_id: int, comment_id: int, content: str) -> Comment raise PermissionDeniedError() updated_comment = self.comment_repository.update_comment(comment, content=content) - return self._process_comment_response(updated_comment) + return self._process_comment_response(user_id, updated_comment) - def list_comments(self, review_id: int) -> list[CommentResponse]: + + def review_comments(self, review_id: int) -> list[CommentResponse]: review = self.review_repository.get_review_by_review_id(review_id) if review is None : raise ReviewNotFoundError() - comments = self.comment_repository.get_comments(review_id) - return [self._process_comment_response(comment) for comment in comments] + comments = self.comment_repository.get_comments_by_review_id(review_id) + return [self._process_comment_response(-1, comment) for comment in comments] + + def review_user_comments(self, user_id: int, review_id: int) -> list[CommentResponse]: + review = self.review_repository.get_review_by_review_id(review_id) + if review is None : + raise ReviewNotFoundError() + + comments = self.comment_repository.get_comments_by_review_id(review_id) + return [self._process_comment_response(user_id, comment) for comment in comments] + + def user_comments(self, user_id: int) -> list[CommentResponse]: + comments = self.comment_repository.get_comments_by_user_id(user_id) + return [self._process_comment_response(user_id, comment) for comment in comments] + def like_comment(self, user_id: int, comment_id: int) -> CommentResponse : comment = self.comment_repository.get_comment_by_comment_id(comment_id) @@ -49,9 +63,18 @@ def like_comment(self, user_id: int, comment_id: int) -> CommentResponse : raise CommentNotFoundError() updated_comment = self.comment_repository.like_comment(user_id, comment) - return self._process_comment_response(updated_comment) + return self._process_comment_response(user_id, updated_comment) + + def delete_comment_by_id(self, user_id: int, comment_id: int) -> None: + comment = self.comment_repository.get_comment_by_comment_id(comment_id) + if comment is None: + raise CommentNotFoundError() + if comment.user_id != user.id: + raise PermissionDeniedError() + self.comment_repository.delete_comment_by_id(comment) + - def _process_comment_response(self, comment: Comment) -> CommentResponse: + def _process_comment_response(self, user_id: int, comment: Comment) -> CommentResponse: return CommentResponse( id=comment.id, user_id=comment.user.id, @@ -60,5 +83,6 @@ def _process_comment_response(self, comment: Comment) -> CommentResponse: review_id=comment.review_id, content=comment.content, likes_count=comment.likes_count, - created_at=comment.created_at + created_at=comment.created_at, + like=self.comment_repository.like_info(user_id, comment) ) diff --git a/watchapedia/app/comment/views.py b/watchapedia/app/comment/views.py index 10db301..ea074e6 100644 --- a/watchapedia/app/comment/views.py +++ b/watchapedia/app/comment/views.py @@ -42,17 +42,42 @@ def update_comment( user.id, comment_id, comment.content ) +@comment_router.get('/list', + status_code=200, + summary="유저 코멘트 출력", + description="유저가 남긴 모든 코멘트들을 반환합니다", + response_model=list[CommentResponse] + ) +def get_comments_by_user( + user: Annotated[User, Depends(login_with_header)], + comment_service: Annotated[CommentService, Depends()], +): + return comment_service.user_comments(user.id) + @comment_router.get('/{review_id}', status_code=200, - summary="코멘트 출력", - description="review_id를 받아 해당 리뷰에 달린 코멘트들을 반환합니다", + summary="비로그인 코멘트 출력", + description="[로그인 불필요] review_id를 받아 해당 리뷰에 달린 코멘트들을 반환합니다", response_model=list[CommentResponse] ) -def get_comments( +def get_comments_by_review( review_id: int, comment_service: Annotated[CommentService, Depends()], ): - return comment_service.list_comments(review_id) + return comment_service.review_comments(review_id) + +@comment_router.get('/list/{review_id}', + status_code=200, + summary="로그인 코멘트 출력", + description="[로그인 필요] review_id를 받아 해당 리뷰에 달린 코멘트들을 포함하여 유저가 해당 코멘트들을 추천했는지 반환합니다", + response_model=list[CommentResponse] + ) +def get_comments_by_review_and_user( + user: Annotated[User, Depends(login_with_header)], + review_id: int, + comment_service: Annotated[CommentService, Depends()], +): + return comment_service.review_user_comments(user.id, review_id) @comment_router.patch('/like/{comment_id}', status_code=200, @@ -65,4 +90,15 @@ def like_comment( comment_id: int, comment_service: Annotated[CommentService, Depends()], ): - return comment_service.like_comment(user.id, comment_id) \ No newline at end of file + return comment_service.like_comment(user.id, comment_id) + +@comment_router.delete('/{comment_id}', + status_code=204, + summary="코멘트 삭제", + description="[로그인 필요] 코멘트 id를 받아 해당 코멘트를 삭제합니다. 성공 시 204 code 반환") +def delete_comment( + user: Annotated[User, Depends(login_with_header)], + comment_id: int, + comment_service: Annotated[CommentService, Depends()] +): + comment_service.delete_comment_by_id(user.id, comment_id) \ No newline at end of file diff --git a/watchapedia/app/review/dto/responses.py b/watchapedia/app/review/dto/responses.py index fd11ad8..922b49e 100644 --- a/watchapedia/app/review/dto/responses.py +++ b/watchapedia/app/review/dto/responses.py @@ -15,4 +15,5 @@ class ReviewResponse(BaseModel): created_at: datetime spoiler: bool status: str | None + like: bool diff --git a/watchapedia/app/review/repository.py b/watchapedia/app/review/repository.py index 156ad78..8502831 100644 --- a/watchapedia/app/review/repository.py +++ b/watchapedia/app/review/repository.py @@ -74,6 +74,18 @@ def get_review_by_review_id(self, review_id: int) -> Review: return review + def like_info(self, user_id: int, review: Review) -> bool: + get_like_query = select(UserLikesReview).filter( + (UserLikesReview.user_id == user_id) + & (UserLikesReview.review_id == review.id) + ) + user_likes_review = self.session.scalar(get_like_query) + + if user_likes_review is None : + return False + else : + return True + def like_review(self, user_id: int, review: Review) -> None: get_like_query = select(UserLikesReview).filter( (UserLikesReview.user_id == user_id) @@ -98,3 +110,7 @@ def like_review(self, user_id: int, review: Review) -> None: self.session.flush() return review + + def delete_review_by_id(self, review: Review) -> None: + self.session.delete(review) + self.session.flush() \ No newline at end of file diff --git a/watchapedia/app/review/service.py b/watchapedia/app/review/service.py index d4c68bf..fa65bed 100644 --- a/watchapedia/app/review/service.py +++ b/watchapedia/app/review/service.py @@ -33,7 +33,7 @@ def create_review(self, user_id: int, movie_id: int, content: str | None, created_at=datetime.now(), spoiler=spoiler, status=status) self.movie_repository.update_average_rating(movie) - return self._process_review_response(new_review) + return self._process_review_response(user_id, new_review) def update_review(self, user_id: int, review_id: int, content: str | None, rating: float | None, spoiler: bool | None, status: str | None @@ -48,7 +48,8 @@ def update_review(self, user_id: int, review_id: int, content: str | None, spoiler=spoiler, status=status) self.movie_repository.update_average_rating(movie) - return self._process_review_response(updated_review) + return self._process_review_response(user_id, updated_review) + def movie_reviews(self, movie_id: int) -> list[ReviewResponse]: movie = self.movie_repository.get_movie_by_movie_id(movie_id) @@ -56,18 +57,36 @@ def movie_reviews(self, movie_id: int) -> list[ReviewResponse]: raise MovieNotFoundError() reviews = self.review_repository.get_reviews_by_movie_id(movie_id) - return [self._process_review_response(review) for review in reviews] + return [self._process_review_response(-1, review) for review in reviews] + + def movie_user_reviews(self, user_id: int, movie_id: int) -> list[ReviewResponse]: + movie = self.movie_repository.get_movie_by_movie_id(movie_id) + if movie is None : + raise MovieNotFoundError() + + reviews = self.review_repository.get_reviews_by_movie_id(movie_id) + return [self._process_review_response(user_id, review) for review in reviews] + + def user_reviews(self, user_id: int) -> list[ReviewResponse]: + reviews = self.review_repository.get_reviews_by_user_id(user_id) + return [self._process_review_response(user_id, review) for review in reviews] - def user_reviews(self, user: User) -> list[ReviewResponse]: - reviews = self.review_repository.get_reviews_by_user_id(user.id) - return [self._process_review_response(review) for review in reviews] def like_review(self, user_id: int, review_id: int) -> ReviewResponse : review = self.review_repository.get_review_by_review_id(review_id) updated_review = self.review_repository.like_review(user_id, review) - return self._process_review_response(updated_review) + return self._process_review_response(user_id, updated_review) + + def delete_review_by_id(self, user_id: int, review_id: int) -> None: + review = self.review_repository.get_review_by_review_id(review_id) + if review is None: + raise ReviewNotFoundError() + if review.user_id != user_id: + raise PermissionDeniedError() + self.review_repository.delete_review_by_id(review) + - def _process_review_response(self, review: Review) -> ReviewResponse: + def _process_review_response(self, user_id: int, review: Review) -> ReviewResponse: return ReviewResponse( id=review.id, user_id=review.user.id, @@ -79,5 +98,6 @@ def _process_review_response(self, review: Review) -> ReviewResponse: likes_count=review.likes_count, created_at=review.created_at, spoiler=review.spoiler, - status=review.status + status=review.status, + like=self.review_repository.like_info(user_id, review) ) diff --git a/watchapedia/app/review/views.py b/watchapedia/app/review/views.py index 4bc7881..313ab2b 100644 --- a/watchapedia/app/review/views.py +++ b/watchapedia/app/review/views.py @@ -45,19 +45,19 @@ def update_review( @review_router.get('/list', status_code=200, summary="유저 리뷰 출력", - description="유저가 남긴 모든 리뷰들을 반환합니다다", + description="유저가 남긴 모든 리뷰들을 반환합니다", response_model=list[ReviewResponse] ) def get_reviews_by_user( user: Annotated[User, Depends(login_with_header)], review_service: Annotated[ReviewService, Depends()], ): - return review_service.user_reviews(user) + return review_service.user_reviews(user.id) -@review_router.get('/list/{movie_id}', +@review_router.get('/{movie_id}', status_code=200, - summary="영화 리뷰 출력", - description="movie_id를 받아 해당 영화에 달린 리뷰들을 반환합니다", + summary="비로그인 리뷰 출력", + description="[로그인 불필요] movie_id를 받아 해당 영화에 달린 리뷰들을 반환합니다", response_model=list[ReviewResponse] ) def get_reviews_by_movie( @@ -65,6 +65,19 @@ def get_reviews_by_movie( review_service: Annotated[ReviewService, Depends()], ): return review_service.movie_reviews(movie_id) + +@review_router.get('list/{movie_id}', + status_code=200, + summary="로그인 리뷰 출력", + description="[로그인 필요] 유저가 남긴 모든 리뷰들을 반환합니다", + response_model=list[ReviewResponse] + ) +def get_reviews_by_movie_and_user( + user: Annotated[User, Depends(login_with_header)], + movie_id: int, + review_service: Annotated[ReviewService, Depends()], +): + return review_service.movie_user_reviews(user.id, movie_id) @review_router.patch('/like/{review_id}', status_code=200, @@ -77,4 +90,15 @@ def like_review( review_id: int, review_service: Annotated[ReviewService, Depends()], ): - return review_service.like_review(user.id, review_id) \ No newline at end of file + return review_service.like_review(user.id, review_id) + +@review_router.delete('/{review_id}', + status_code=204, + summary="리뷰 삭제", + description="[로그인 필요] 리뷰 id를 받아 해당 리뷰를 삭제합니다. 성공 시 204 code 반환") +def delete_review( + review_id: int, + user: Annotated[User, Depends(login_with_header)], + review_service: Annotated[ReviewService, Depends()] +): + review_service.delete_review_by_id(user.id, review_id) \ No newline at end of file diff --git a/watchapedia/database/alembic/versions/2025_01_19_1247-199025363746_.py b/watchapedia/database/alembic/versions/2025_01_19_1247-199025363746_.py new file mode 100644 index 0000000..de2ff10 --- /dev/null +++ b/watchapedia/database/alembic/versions/2025_01_19_1247-199025363746_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: 199025363746 +Revises: 28d5dee178b9 +Create Date: 2025-01-19 12:47:34.730166 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '199025363746' +down_revision: Union[str, None] = '28d5dee178b9' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/watchapedia/database/alembic/versions/2025_01_19_1247-28d5dee178b9_.py b/watchapedia/database/alembic/versions/2025_01_19_1247-28d5dee178b9_.py new file mode 100644 index 0000000..aa032e0 --- /dev/null +++ b/watchapedia/database/alembic/versions/2025_01_19_1247-28d5dee178b9_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: 28d5dee178b9 +Revises: d6a643d1533a +Create Date: 2025-01-19 12:47:29.696519 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '28d5dee178b9' +down_revision: Union[str, None] = 'd6a643d1533a' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### From e45718725d604d38fafa90de65eca998485917aa Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 20 Jan 2025 18:26:05 +0000 Subject: [PATCH 21/26] =?UTF-8?q?Review=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.local:Zone.Identifier | 0 watchapedia/app/comment/views.py | 2 +- watchapedia/app/review/service.py | 2 +- watchapedia/app/review/views.py | 4 +-- .../versions/2025_01_19_1247-199025363746_.py | 30 ------------------- .../versions/2025_01_19_1247-28d5dee178b9_.py | 30 ------------------- 6 files changed, 4 insertions(+), 64 deletions(-) delete mode 100644 .env.local:Zone.Identifier delete mode 100644 watchapedia/database/alembic/versions/2025_01_19_1247-199025363746_.py delete mode 100644 watchapedia/database/alembic/versions/2025_01_19_1247-28d5dee178b9_.py diff --git a/.env.local:Zone.Identifier b/.env.local:Zone.Identifier deleted file mode 100644 index e69de29..0000000 diff --git a/watchapedia/app/comment/views.py b/watchapedia/app/comment/views.py index ea074e6..b62b85f 100644 --- a/watchapedia/app/comment/views.py +++ b/watchapedia/app/comment/views.py @@ -101,4 +101,4 @@ def delete_comment( comment_id: int, comment_service: Annotated[CommentService, Depends()] ): - comment_service.delete_comment_by_id(user.id, comment_id) \ No newline at end of file + comment_service.delete_comment_by_id(user.id, comment_id) diff --git a/watchapedia/app/review/service.py b/watchapedia/app/review/service.py index fa65bed..782181e 100644 --- a/watchapedia/app/review/service.py +++ b/watchapedia/app/review/service.py @@ -7,7 +7,7 @@ from watchapedia.app.review.dto.responses import ReviewResponse from watchapedia.app.review.repository import ReviewRepository from watchapedia.app.review.models import Review -from watchapedia.app.review.errors import RedundantReviewError +from watchapedia.app.review.errors import RedundantReviewError, ReviewNotFoundError from datetime import datetime class ReviewService: diff --git a/watchapedia/app/review/views.py b/watchapedia/app/review/views.py index 313ab2b..90fc50d 100644 --- a/watchapedia/app/review/views.py +++ b/watchapedia/app/review/views.py @@ -66,7 +66,7 @@ def get_reviews_by_movie( ): return review_service.movie_reviews(movie_id) -@review_router.get('list/{movie_id}', +@review_router.get('/list/{movie_id}', status_code=200, summary="로그인 리뷰 출력", description="[로그인 필요] 유저가 남긴 모든 리뷰들을 반환합니다", @@ -101,4 +101,4 @@ def delete_review( user: Annotated[User, Depends(login_with_header)], review_service: Annotated[ReviewService, Depends()] ): - review_service.delete_review_by_id(user.id, review_id) \ No newline at end of file + review_service.delete_review_by_id(user.id, review_id) diff --git a/watchapedia/database/alembic/versions/2025_01_19_1247-199025363746_.py b/watchapedia/database/alembic/versions/2025_01_19_1247-199025363746_.py deleted file mode 100644 index de2ff10..0000000 --- a/watchapedia/database/alembic/versions/2025_01_19_1247-199025363746_.py +++ /dev/null @@ -1,30 +0,0 @@ -"""empty message - -Revision ID: 199025363746 -Revises: 28d5dee178b9 -Create Date: 2025-01-19 12:47:34.730166 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '199025363746' -down_revision: Union[str, None] = '28d5dee178b9' -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! ### - pass - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### diff --git a/watchapedia/database/alembic/versions/2025_01_19_1247-28d5dee178b9_.py b/watchapedia/database/alembic/versions/2025_01_19_1247-28d5dee178b9_.py deleted file mode 100644 index aa032e0..0000000 --- a/watchapedia/database/alembic/versions/2025_01_19_1247-28d5dee178b9_.py +++ /dev/null @@ -1,30 +0,0 @@ -"""empty message - -Revision ID: 28d5dee178b9 -Revises: d6a643d1533a -Create Date: 2025-01-19 12:47:29.696519 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '28d5dee178b9' -down_revision: Union[str, None] = 'd6a643d1533a' -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! ### - pass - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - pass - # ### end Alembic commands ### From f99939e0248bfa1b2e41b99992aef6ae9cb14032 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Tue, 21 Jan 2025 14:23:28 +0000 Subject: [PATCH 22/26] =?UTF-8?q?=EB=B3=80=EC=88=98=EB=AA=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/comment/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/watchapedia/app/comment/service.py b/watchapedia/app/comment/service.py index 995cc7a..4f9e542 100644 --- a/watchapedia/app/comment/service.py +++ b/watchapedia/app/comment/service.py @@ -69,7 +69,7 @@ def delete_comment_by_id(self, user_id: int, comment_id: int) -> None: comment = self.comment_repository.get_comment_by_comment_id(comment_id) if comment is None: raise CommentNotFoundError() - if comment.user_id != user.id: + if comment.user_id != user_id: raise PermissionDeniedError() self.comment_repository.delete_comment_by_id(comment) From d56bfc7728bbc836872a2bb9da787f659cc35baa Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 22 Jan 2025 09:48:54 +0000 Subject: [PATCH 23/26] =?UTF-8?q?Review=20delete=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/comment/models.py | 6 +-- watchapedia/app/comment/repository.py | 4 +- watchapedia/app/review/models.py | 6 +-- .../versions/2025_01_22_0908-d9b77a0521b4_.py | 44 +++++++++++++++++++ .../versions/2025_01_22_0929-687c2bb0a8aa_.py | 30 +++++++++++++ .../versions/2025_01_22_0942-f8829a40f8a3_.py | 30 +++++++++++++ .../versions/2025_01_22_0946-2067eeb29d39_.py | 30 +++++++++++++ 7 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 watchapedia/database/alembic/versions/2025_01_22_0908-d9b77a0521b4_.py create mode 100644 watchapedia/database/alembic/versions/2025_01_22_0929-687c2bb0a8aa_.py create mode 100644 watchapedia/database/alembic/versions/2025_01_22_0942-f8829a40f8a3_.py create mode 100644 watchapedia/database/alembic/versions/2025_01_22_0946-2067eeb29d39_.py diff --git a/watchapedia/app/comment/models.py b/watchapedia/app/comment/models.py index 68e2556..3183924 100644 --- a/watchapedia/app/comment/models.py +++ b/watchapedia/app/comment/models.py @@ -13,12 +13,12 @@ class Comment(Base): created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("user.id"), nullable=False + Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) user: Mapped["User"] = relationship("User", back_populates="comments") review_id: Mapped[int] = mapped_column( - Integer, ForeignKey("review.id"), nullable=False + Integer, ForeignKey("review.id", ondelete="CASCADE"), nullable=False ) review: Mapped["Review"] = relationship("Review", back_populates="comments") @@ -31,4 +31,4 @@ class UserLikesComment(Base): ) comment_id: Mapped[int] = mapped_column( Integer, ForeignKey("comment.id"), nullable=False - ) \ No newline at end of file + ) diff --git a/watchapedia/app/comment/repository.py b/watchapedia/app/comment/repository.py index 572f6dc..8a92ab8 100644 --- a/watchapedia/app/comment/repository.py +++ b/watchapedia/app/comment/repository.py @@ -30,8 +30,6 @@ def create_comment(self, user_id: int, review_id: int, content: str, created_at) self.session.add(comment) self.session.flush() - comment = self.get_comment_by_user_and_review(user_id, review_id) - return comment def update_comment(self, comment, content: str) -> Comment: @@ -94,4 +92,4 @@ def like_comment(self, user_id: int, comment: Comment) -> Comment: def delete_comment_by_id(self, comment: Comment) -> None: self.session.delete(comment) - self.session.flush() \ No newline at end of file + self.session.flush() diff --git a/watchapedia/app/review/models.py b/watchapedia/app/review/models.py index 9a757eb..b8ee68e 100644 --- a/watchapedia/app/review/models.py +++ b/watchapedia/app/review/models.py @@ -20,16 +20,16 @@ class Review(Base): status: Mapped[str] = mapped_column(String(20), nullable=True) user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("user.id"), nullable=False + Integer, ForeignKey("user.id", ondelete="CASCADE"), nullable=False ) user: Mapped["User"] = relationship("User", back_populates="reviews") movie_id: Mapped[int] = mapped_column( - Integer, ForeignKey("movie.id"), nullable=False + Integer, ForeignKey("movie.id", ondelete="CASCADE"), nullable=False ) movie: Mapped["Movie"] = relationship("Movie", back_populates="reviews") - comments: Mapped[list["Comment"]] = relationship("Comment", back_populates="review") + comments: Mapped[list["Comment"]] = relationship("Comment", back_populates="review", cascade="all, delete, delete-orphan") class UserLikesReview(Base): __tablename__ = 'user_likes_review' diff --git a/watchapedia/database/alembic/versions/2025_01_22_0908-d9b77a0521b4_.py b/watchapedia/database/alembic/versions/2025_01_22_0908-d9b77a0521b4_.py new file mode 100644 index 0000000..cf1054d --- /dev/null +++ b/watchapedia/database/alembic/versions/2025_01_22_0908-d9b77a0521b4_.py @@ -0,0 +1,44 @@ +"""empty message + +Revision ID: d9b77a0521b4 +Revises: d6a643d1533a +Create Date: 2025-01-22 09:08:48.068987 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd9b77a0521b4' +down_revision: Union[str, None] = 'd6a643d1533a' +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! ### + op.drop_constraint('comment_ibfk_2', 'comment', type_='foreignkey') + op.drop_constraint('comment_ibfk_1', 'comment', type_='foreignkey') + op.create_foreign_key(None, 'comment', 'user', ['user_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key(None, 'comment', 'review', ['review_id'], ['id'], ondelete='CASCADE') + op.drop_constraint('review_ibfk_1', 'review', type_='foreignkey') + op.drop_constraint('review_ibfk_2', 'review', type_='foreignkey') + op.create_foreign_key(None, 'review', 'user', ['user_id'], ['id'], ondelete='CASCADE') + op.create_foreign_key(None, 'review', 'movie', ['movie_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'review', type_='foreignkey') + op.drop_constraint(None, 'review', type_='foreignkey') + op.create_foreign_key('review_ibfk_2', 'review', 'user', ['user_id'], ['id']) + op.create_foreign_key('review_ibfk_1', 'review', 'movie', ['movie_id'], ['id']) + op.drop_constraint(None, 'comment', type_='foreignkey') + op.drop_constraint(None, 'comment', type_='foreignkey') + op.create_foreign_key('comment_ibfk_1', 'comment', 'review', ['review_id'], ['id']) + op.create_foreign_key('comment_ibfk_2', 'comment', 'user', ['user_id'], ['id']) + # ### end Alembic commands ### diff --git a/watchapedia/database/alembic/versions/2025_01_22_0929-687c2bb0a8aa_.py b/watchapedia/database/alembic/versions/2025_01_22_0929-687c2bb0a8aa_.py new file mode 100644 index 0000000..cf2f2b7 --- /dev/null +++ b/watchapedia/database/alembic/versions/2025_01_22_0929-687c2bb0a8aa_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: 687c2bb0a8aa +Revises: d9b77a0521b4 +Create Date: 2025-01-22 09:29:14.572112 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '687c2bb0a8aa' +down_revision: Union[str, None] = 'd9b77a0521b4' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/watchapedia/database/alembic/versions/2025_01_22_0942-f8829a40f8a3_.py b/watchapedia/database/alembic/versions/2025_01_22_0942-f8829a40f8a3_.py new file mode 100644 index 0000000..b88f90a --- /dev/null +++ b/watchapedia/database/alembic/versions/2025_01_22_0942-f8829a40f8a3_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: f8829a40f8a3 +Revises: 687c2bb0a8aa +Create Date: 2025-01-22 09:42:12.101568 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f8829a40f8a3' +down_revision: Union[str, None] = '687c2bb0a8aa' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/watchapedia/database/alembic/versions/2025_01_22_0946-2067eeb29d39_.py b/watchapedia/database/alembic/versions/2025_01_22_0946-2067eeb29d39_.py new file mode 100644 index 0000000..fd6dfcf --- /dev/null +++ b/watchapedia/database/alembic/versions/2025_01_22_0946-2067eeb29d39_.py @@ -0,0 +1,30 @@ +"""empty message + +Revision ID: 2067eeb29d39 +Revises: f8829a40f8a3 +Create Date: 2025-01-22 09:46:55.949966 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2067eeb29d39' +down_revision: Union[str, None] = 'f8829a40f8a3' +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! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### From 7c7d47c9dfa783e1bfb9464fb5506d00f287b555 Mon Sep 17 00:00:00 2001 From: anandashin Date: Wed, 22 Jan 2025 19:48:05 +0900 Subject: [PATCH 24/26] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EC=BB=AC?= =?UTF-8?q?=EB=A0=89=EC=85=98=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/collection/views.py | 16 ++++++++++++++++ watchapedia/settings.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/watchapedia/app/collection/views.py b/watchapedia/app/collection/views.py index b62aa77..5db2ab0 100644 --- a/watchapedia/app/collection/views.py +++ b/watchapedia/app/collection/views.py @@ -2,6 +2,8 @@ from fastapi.responses import JSONResponse from typing import Annotated from datetime import datetime +from watchapedia.app.user.errors import UserNotFoundError +from watchapedia.app.user.service import UserService from watchapedia.app.user.views import login_with_header from watchapedia.app.user.models import User from watchapedia.app.collection.service import CollectionService @@ -84,3 +86,17 @@ def delete_collection( ): collection_service.delete_collection_by_id(collection_id, user) +@collection_router.get("/list/{user_id}", + status_code=200, + summary="유저 컬렉션 리스트 조회", + description="[로그인 불필요] 유저 아이디를 받아 해당 유저의 컬렉션들을 조회합니다.") +def search_collection_list( + user_id: int, + collection_service: Annotated[CollectionService, Depends()], + user_service: Annotated[UserService, Depends()] +): + user = user_service.get_user_by_user_id(user_id) + if user is None: + raise UserNotFoundError() + return collection_service.get_user_collections(user) + diff --git a/watchapedia/settings.py b/watchapedia/settings.py index de07509..138382a 100644 --- a/watchapedia/settings.py +++ b/watchapedia/settings.py @@ -2,7 +2,7 @@ from pydantic_settings import BaseSettings -ENV = os.getenv("ENV", "prod") # 배포 시에는(merge) prod로 바꿔주세요 +ENV = os.getenv("ENV", "local") # 배포 시에는(merge) prod로 바꿔주세요 assert ENV in ("local", "prod") From eac6ff52ce6aa20175f8dddf889084f7d941015d Mon Sep 17 00:00:00 2001 From: anandashin Date: Wed, 22 Jan 2025 20:06:01 +0900 Subject: [PATCH 25/26] =?UTF-8?q?fix:=20ratings=5Fcount=20-=20rating=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8=20=EC=97=AC=EB=B6=80=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/app/movie/service.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/watchapedia/app/movie/service.py b/watchapedia/app/movie/service.py index 3a03688..8b58bce 100644 --- a/watchapedia/app/movie/service.py +++ b/watchapedia/app/movie/service.py @@ -8,6 +8,7 @@ from watchapedia.app.movie.models import Movie from watchapedia.app.movie.dto.requests import AddParticipantsRequest, AddMovieListRequest from watchapedia.app.movie.dto.responses import MovieDataResponse, ParticipantsDataResponse +from watchapedia.app.review.models import Review class MovieService(): def __init__(self, @@ -176,7 +177,7 @@ def _process_movie_response(self, movie: Movie) -> MovieDataResponse: ], synopsis=movie.synopsis, average_rating=movie.average_rating, - ratings_count=len(movie.reviews), + ratings_count=self._count_ratings(movie.reviews), running_time=movie.running_time, grade=movie.grade, poster_url=movie.poster_url, @@ -192,4 +193,9 @@ def _process_movie_response(self, movie: Movie) -> MovieDataResponse: ] ) - + def _count_ratings(self, reviews: list[Review]) -> int: + rating_num = 0 + for review in reviews: + if review.rating is not None: + rating_num += 1 + return rating_num From ef2611f4d576c116dd9c040ddb61ac39bc6ba851 Mon Sep 17 00:00:00 2001 From: anandashin Date: Wed, 22 Jan 2025 20:06:34 +0900 Subject: [PATCH 26/26] =?UTF-8?q?chore:=20settings=20prod=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- watchapedia/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/watchapedia/settings.py b/watchapedia/settings.py index 138382a..de07509 100644 --- a/watchapedia/settings.py +++ b/watchapedia/settings.py @@ -2,7 +2,7 @@ from pydantic_settings import BaseSettings -ENV = os.getenv("ENV", "local") # 배포 시에는(merge) prod로 바꿔주세요 +ENV = os.getenv("ENV", "prod") # 배포 시에는(merge) prod로 바꿔주세요 assert ENV in ("local", "prod")