diff --git a/backend/backlog_app/api/crud.py b/backend/backlog_app/api/crud.py index 3e5c776..fed21a3 100644 --- a/backend/backlog_app/api/crud.py +++ b/backend/backlog_app/api/crud.py @@ -1,4 +1,5 @@ import logging +from uuid import UUID from fastapi import HTTPException from sqlalchemy import or_ @@ -8,7 +9,7 @@ from backlog_app.models import User from backlog_app.models.movie import Movie -from backlog_app.schemas.movie import MovieCreate, MovieRead, MovieUpdate +from backlog_app.schemas.movie import MovieCreate, MovieList, MovieRead, MovieUpdate logger = logging.getLogger(__name__) @@ -16,29 +17,17 @@ async def create_movie( db: AsyncSession, movie_in: MovieCreate, user: User ) -> MovieRead: - movie = Movie(**movie_in.model_dump(), user=user) + movie = Movie(**movie_in.model_dump(), user_id=user.id) db.add(movie) await db.commit() await db.refresh(movie) logger.info("Movie <%s> has been created.", movie.id) - return MovieRead( - id=movie.id, - title=movie.title, - description=movie.description, - year=movie.year, - rating=movie.rating, - kp_id=movie.kp_id, - imdb_id=movie.imdb_id, - watch_link=movie.watch_link, - watched=movie.watched, - created_at=movie.created_at, - user=user.email.split("@")[0], - ) + return MovieRead.model_validate(movie) -async def get_movies(db: AsyncSession, user_id: str | None = None): +async def get_movies(db: AsyncSession, user_id: str | None = None) -> MovieList: query = select(Movie).options(selectinload(Movie.user)) if user_id: @@ -49,26 +38,13 @@ async def get_movies(db: AsyncSession, user_id: str | None = None): result = await db.execute(query) movies = result.scalars().all() - return [ - MovieRead( - id=m.id, - title=m.title, - description=m.description, - year=m.year, - rating=m.rating, - imdb_id=m.imdb_id, - watch_link=m.watch_link, - watched=m.watched, - created_at=m.created_at, - kp_id=m.kp_id, - user=m.user.email.split("@")[0], - ) - for m in movies - ] + logger.debug("Size of movies list: %s", len(movies)) + + return MovieList.model_validate({"movies": movies}) async def get_movie_by_id( - db: AsyncSession, movie_id: int, user_id: int | None = None + db: AsyncSession, movie_id: int, user_id: UUID | None = None ) -> MovieRead | None: query = select(Movie).options(selectinload(Movie.user)).where(Movie.id == movie_id) @@ -83,19 +59,7 @@ async def get_movie_by_id( logger.info("Movie has been found.") - return MovieRead( - id=movie.id, - title=movie.title, - description=movie.description, - year=movie.year, - rating=movie.rating, - imdb_id=movie.imdb_id, - watch_link=movie.watch_link, - watched=movie.watched, - created_at=movie.created_at, - kp_id=movie.kp_id, - user=movie.user.email.split("@")[0], - ) + return MovieRead.model_validate(movie) async def update_movie( @@ -122,19 +86,7 @@ async def update_movie( logger.info("Movie has been updated.") - return MovieRead( - id=movie.id, - title=movie.title, - description=movie.description, - year=movie.year, - rating=movie.rating, - kp_id=movie.kp_id, - imdb_id=movie.imdb_id, - watch_link=movie.watch_link, - watched=movie.watched, - user=movie.user.email.split("@")[0], - created_at=movie.created_at.isoformat(), - ) + return MovieRead.model_validate(movie) async def partial_update_movie( @@ -157,7 +109,7 @@ async def delete_movie( db: AsyncSession, movie_id: int, user: User, -) -> None: +) -> MovieRead: result = await db.execute(select(Movie).where(Movie.id == movie_id)) movie = result.scalars().first() if not movie: @@ -170,6 +122,8 @@ async def delete_movie( logger.info("Movie <%s> has been deleted.", movie_id) + return MovieRead.model_validate(movie) + def check_movie_ownership(movie: Movie, user: User) -> None: """Проверяет, может ли пользователь изменять фильм""" diff --git a/backend/backlog_app/api/view/movie_view.py b/backend/backlog_app/api/view/movie_view.py index 8a8cc2d..c103f87 100644 --- a/backend/backlog_app/api/view/movie_view.py +++ b/backend/backlog_app/api/view/movie_view.py @@ -8,7 +8,7 @@ current_active_user, ) from backlog_app.models.users import User -from backlog_app.schemas.movie import MovieCreate, MovieRead, MovieUpdate +from backlog_app.schemas.movie import MovieCreate, MovieList, MovieRead, MovieUpdate from backlog_app.storages.database import get_async_session router = APIRouter(prefix="/movies", tags=["Movies"]) @@ -23,7 +23,7 @@ async def add_movie( return await crud.create_movie(db, movie_create, user=user) -@router.get("/", response_model=List[MovieRead]) +@router.get("/", response_model=MovieList) async def get_movie_list( db: Annotated[AsyncSession, Depends(get_async_session)], user: Annotated[User, Depends(current_active_user)], diff --git a/backend/backlog_app/models/movie.py b/backend/backlog_app/models/movie.py index 6cce5b5..d3b7222 100644 --- a/backend/backlog_app/models/movie.py +++ b/backend/backlog_app/models/movie.py @@ -74,4 +74,4 @@ class Movie(Base): user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("user.id"), nullable=False ) - user: Mapped["User"] = relationship("User", back_populates="movies") + user: Mapped["User"] = relationship("User", back_populates="movies", lazy="joined") diff --git a/backend/backlog_app/schemas/helper.py b/backend/backlog_app/schemas/helper.py new file mode 100644 index 0000000..2e21f6b --- /dev/null +++ b/backend/backlog_app/schemas/helper.py @@ -0,0 +1,3 @@ +def to_camel_case(snake_str: str) -> str: + parts = snake_str.split("_") + return parts[0] + "".join(word.capitalize() for word in parts[1:]) diff --git a/backend/backlog_app/schemas/movie.py b/backend/backlog_app/schemas/movie.py index e9ee8ec..414b172 100644 --- a/backend/backlog_app/schemas/movie.py +++ b/backend/backlog_app/schemas/movie.py @@ -2,8 +2,9 @@ from typing import Annotated from annotated_types import Len -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field +from .helper import to_camel_case from .user import UserRead @@ -15,15 +16,19 @@ class MovieBase(BaseModel): watch_link: str | None = None kp_id: int | None = None imdb_id: int | None = None + published: bool = False - model_config = {"from_attributes": True} + model_config = ConfigDict( + from_attributes=True, + alias_generator=to_camel_case, + validate_by_name=True, + ) class MovieCreate(MovieBase): description: Annotated[str, Len(min_length=20, max_length=1000)] | None = None year: int | None = None rating: float | None = Field(default=None, ge=1.0, le=10.0) - published: bool = False class MovieUpdate(MovieBase): @@ -32,14 +37,17 @@ class MovieUpdate(MovieBase): year: int | None = None watched: bool | None = None rating: float | None = Field(default=None, ge=1.0, le=10.0) - published: bool = False class MovieRead(MovieBase): id: int - user: str + user: UserRead description: Annotated[str, Len(min_length=20, max_length=1000)] | None year: int | None watched: bool rating: float | None created_at: datetime + + +class MovieList(BaseModel): + movies: list[MovieRead] diff --git a/backend/backlog_app/schemas/user.py b/backend/backlog_app/schemas/user.py index 43165e0..75f312b 100644 --- a/backend/backlog_app/schemas/user.py +++ b/backend/backlog_app/schemas/user.py @@ -1,12 +1,23 @@ -import uuid from typing import Annotated +from uuid import UUID from annotated_types import Len from fastapi_users import schemas +from pydantic import BaseModel, ConfigDict, EmailStr, computed_field +from .helper import to_camel_case -class UserRead(schemas.BaseUser[uuid.UUID]): - pass + +class UserRead(BaseModel): + id: UUID + email: EmailStr + + @computed_field + @property + def username(self) -> str: + return self.email.split("@")[0] + + model_config = ConfigDict(from_attributes=True, alias_generator=to_camel_case) class UserCreate(schemas.BaseUserCreate): diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index c246f97..d199685 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -32,6 +32,8 @@ async def init_db(): await connection.run_sync(Base.metadata.create_all) yield + await engine_test.dispose() + if os.path.exists(DB_PATH): os.remove(DB_PATH) diff --git a/backend/tests/test_schemas/test_movie.py b/backend/tests/test_schemas/test_movie.py index 87ce9f3..15a666f 100644 --- a/backend/tests/test_schemas/test_movie.py +++ b/backend/tests/test_schemas/test_movie.py @@ -1,9 +1,11 @@ from datetime import datetime +from uuid import uuid4 import pytest from pydantic import ValidationError from backlog_app.schemas.movie import MovieCreate, MovieRead, MovieUpdate +from backlog_app.schemas.user import UserRead def test_movie_can_be_create_from_create_schema() -> None: @@ -13,12 +15,13 @@ def test_movie_can_be_create_from_create_schema() -> None: watch_link="https://example.com", rating=6.7, ) + user = UserRead(id=uuid4(), email="test@example.com") movie = MovieRead( **movie_in.model_dump(), id=0, watched=False, created_at=datetime.now(), - user="test", + user=user, ) assert movie_in.title == movie.title @@ -50,12 +53,13 @@ def test_movie_create_max_value( title=title, description=description, ) + user = UserRead(id=uuid4(), email="test@example.com") movie = MovieRead( **movie_in.model_dump(), id=0, watched=False, created_at=datetime.now(), - user="test", + user=user, ) assert movie.title == title @@ -63,9 +67,11 @@ def test_movie_create_max_value( def test_movie_update_from_update_schema() -> None: + user = UserRead(id=uuid4(), email="test@example.com") + movie = MovieRead( id=0, - user="test", + user=user, title="Test Movie", description="Test Movie Description", watch_link="https://example.com", @@ -81,7 +87,7 @@ def test_movie_update_from_update_schema() -> None: description="Test Movie Update Description", watch_link="https://abc.example.com", ) - for field, value in movie_update: + for field, value in movie_update.model_dump(exclude_unset=True).items(): setattr(movie, field, value) assert movie_update.title == movie.title diff --git a/backend/tests/test_schemas/test_users.py b/backend/tests/test_schemas/test_users.py index f226ae9..687a24c 100644 --- a/backend/tests/test_schemas/test_users.py +++ b/backend/tests/test_schemas/test_users.py @@ -38,13 +38,8 @@ def test_user_read(): data = { "id": user_id, "email": "read@example.com", - "is_active": True, - "is_superuser": False, - "is_verified": True, } user = UserRead(**data) assert user.id == user_id assert user.email == data["email"] - assert user.is_active is True - assert user.is_superuser is False - assert user.is_verified is True + assert user.username == "read" diff --git a/frontend/src/components/MovieForm.vue b/frontend/src/components/MovieForm.vue index bdfc523..e077841 100644 --- a/frontend/src/components/MovieForm.vue +++ b/frontend/src/components/MovieForm.vue @@ -79,14 +79,14 @@ Watch Link (optional) -
{{ errors.watch_link }}
+{{ errors.watchLink }}
@@ -95,15 +95,15 @@ Kinopoisk ID (optional) -{{ errors.kp_id }}
+{{ errors.kpId }}
@@ -112,15 +112,15 @@ IMDB ID (optional) -{{ errors.imdb_id }}
+{{ errors.imdbId }}
@@ -227,9 +227,9 @@ export default { description: '', year: null, rating: null, - watch_link: '', - kp_id: null, - imdb_id: null, + watchLink: '', + kpId: null, + imdbId: null, watched: false, published: false, editingId: null, @@ -258,9 +258,9 @@ export default { description: '', year: '', rating: '', - watch_link: '', - kp_id: '', - imdb_id: '', + watchLink: '', + kpId: '', + imdbId: '', } let isValid = true @@ -291,24 +291,24 @@ export default { } // URL validation (optional, but if provided must be valid) - if (this.watch_link && this.watch_link.trim() !== '') { + if (this.watchLink && this.watchLink.trim() !== '') { try { - new URL(this.watch_link) + new URL(this.watchLink) } catch (e) { - this.errors.watch_link = 'Please enter a valid URL (e.g., https://example.com)' + this.errors.watchLink = 'Please enter a valid URL (e.g., https://example.com)' isValid = false } } // KP ID validation (optional, but if provided must be positive) - if (this.kp_id !== null && this.kp_id !== '' && this.kp_id < 0) { - this.errors.kp_id = 'Kinopoisk ID must be a positive number' + if (this.kpId !== null && this.kpId !== '' && this.kpId < 0) { + this.errors.kpId = 'Kinopoisk ID must be a positive number' isValid = false } // IMDB ID validation (optional, but if provided must be positive) - if (this.imdb_id !== null && this.imdb_id !== '' && this.imdb_id < 0) { - this.errors.imdb_id = 'IMDB ID must be a positive number' + if (this.imdbId !== null && this.imdbId !== '' && this.imdbId < 0) { + this.errors.imdbId = 'IMDB ID must be a positive number' isValid = false } @@ -328,9 +328,9 @@ export default { description: this.description.trim() || null, year: this.year || null, rating: this.rating || null, - watch_link: this.watch_link.trim() || null, - kp_id: this.kp_id || null, - imdb_id: this.imdb_id || null, + watch_link: this.watchLink.trim() || null, + kp_id: this.kpId || null, + imdb_id: this.imdbId || null, watched: this.watched, published: this.published } @@ -365,9 +365,9 @@ export default { description: this.description.trim() || null, year: this.year || null, rating: this.rating || null, - watch_link: this.watch_link.trim() || null, - kp_id: this.kp_id || null, - imdb_id: this.imdb_id || null, + watch_link: this.watchLink.trim() || null, + kp_id: this.kpId || null, + imdb_id: this.imdbId || null, watched: this.watched, published: this.published } @@ -393,9 +393,9 @@ export default { this.description = '' this.year = null this.rating = null - this.watch_link = '' - this.kp_id = null - this.imdb_id = null + this.watchLink = '' + this.kpId = null + this.imdbId = null this.watched = false this.published = false this.editingId = null @@ -404,9 +404,9 @@ export default { description: '', year: '', rating: '', - watch_link: '', - kp_id: '', - imdb_id: '' + watchLink: '', + kpId: '', + imdbId: '' } this.errorMessage = '' }, @@ -416,9 +416,9 @@ export default { this.description = movie.description || '' this.year = movie.year || null this.rating = movie.rating || null - this.watch_link = movie.watch_link || '' - this.kp_id = movie.kp_id || null - this.imdb_id = movie.imdb_id || null + this.watchLink = movie.watchLink || '' + this.kpId = movie.kpId || null + this.imdbId = movie.imdbId || null this.watched = movie.watched || false this.published = movie.published || false this.editingId = movie.id diff --git a/frontend/src/components/MovieItem.vue b/frontend/src/components/MovieItem.vue index 9ec74e6..c999d7b 100644 --- a/frontend/src/components/MovieItem.vue +++ b/frontend/src/components/MovieItem.vue @@ -26,7 +26,7 @@