From c47115452d58700e2ddb2faa980cfb35d0c1aefa Mon Sep 17 00:00:00 2001 From: Nikita Yakovlev Date: Mon, 31 Aug 2026 00:25:35 +0300 Subject: [PATCH 1/6] Implement many-to-many movie sharing - Add UserMovie join table with per-user watched/note/rating state - Migrate watched/note/rating fields from Movie to UserMovie - Add POST/DELETE /movies/{slug}/join endpoints - Update MovieRead schema with is_joined and joined_by fields - Update GET /movies/ to include creator's own unpublished movies - Add join/leave actions and joined-by list on frontend - Restrict title/description/year edits to owner only - Remove redundant get_or_create_user_movie wrapper - Remove unnecessary DB queries in delete_movie (204 response) - Fix dead movie-is-None guard in update_movie_metadata - Add many-to-many integration tests Co-Authored-By: Claude Sonnet 4.6 --- ...0001-a1b2c3d4e5f6_add_user_movies_table.py | 87 +++++ backend/backlog_app/api/crud.py | 286 +++++++++++++-- backend/backlog_app/api/view/movie_view.py | 75 +++- backend/backlog_app/models/__init__.py | 1 + backend/backlog_app/models/movie.py | 26 +- backend/backlog_app/models/user_movie.py | 46 +++ backend/backlog_app/models/users.py | 4 + backend/backlog_app/schemas/movie.py | 15 +- backend/backlog_app/tasks/movie_task.py | 11 +- backend/tests/conftest.py | 57 ++- backend/tests/test_many_to_many.py | 336 ++++++++++++++++++ frontend/src/api/movies.ts | 21 +- frontend/src/components/ui/MovieCard.vue | 8 +- frontend/src/stores/movies.ts | 23 ++ frontend/src/views/movies/MovieDetailView.vue | 120 ++++++- 15 files changed, 1018 insertions(+), 98 deletions(-) create mode 100644 backend/backlog_app/alembic/versions/2026_08_23_0001-a1b2c3d4e5f6_add_user_movies_table.py create mode 100644 backend/backlog_app/models/user_movie.py create mode 100644 backend/tests/test_many_to_many.py diff --git a/backend/backlog_app/alembic/versions/2026_08_23_0001-a1b2c3d4e5f6_add_user_movies_table.py b/backend/backlog_app/alembic/versions/2026_08_23_0001-a1b2c3d4e5f6_add_user_movies_table.py new file mode 100644 index 0000000..dbc94d4 --- /dev/null +++ b/backend/backlog_app/alembic/versions/2026_08_23_0001-a1b2c3d4e5f6_add_user_movies_table.py @@ -0,0 +1,87 @@ +"""add user_movies table + +Revision ID: a1b2c3d4e5f6 +Revises: 4dc9128d633a +Create Date: 2026-08-23 00:01:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, Sequence[str], None] = "4dc9128d633a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # 1. Create user_movies table + op.create_table( + "user_movies", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("movie_id", sa.Integer(), nullable=False), + sa.Column("watched", sa.Boolean(), server_default="false", nullable=False), + sa.Column("note", sa.String(length=50), nullable=True), + sa.Column("rating", sa.Float(), nullable=True), + sa.Column( + "joined_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["movie_id"], ["movies.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "movie_id", name="uq_user_movie"), + ) + + # 2. Migrate existing movie data into user_movies + op.execute(""" + INSERT INTO user_movies (user_id, movie_id, watched, note, rating, joined_at) + SELECT user_id, id, watched, note, rating, created_at + FROM movies + """) + + # 3. Drop watched, note, rating from movies + op.drop_column("movies", "watched") + op.drop_column("movies", "note") + op.drop_column("movies", "rating") + + +def downgrade() -> None: + """Downgrade schema.""" + # 1. Re-add columns to movies + op.add_column( + "movies", + sa.Column("watched", sa.Boolean(), server_default="false", nullable=False), + ) + op.add_column( + "movies", + sa.Column("note", sa.String(length=50), nullable=True), + ) + op.add_column( + "movies", + sa.Column("rating", sa.Float(), nullable=True), + ) + + # 2. Restore data from user_movies (owner's row) + op.execute(""" + UPDATE movies + SET + watched = um.watched, + note = um.note, + rating = um.rating + FROM user_movies um + WHERE um.movie_id = movies.id + AND um.user_id = movies.user_id + """) + + # 3. Drop user_movies table + op.drop_table("user_movies") diff --git a/backend/backlog_app/api/crud.py b/backend/backlog_app/api/crud.py index 6516296..9ad9deb 100644 --- a/backend/backlog_app/api/crud.py +++ b/backend/backlog_app/api/crud.py @@ -5,44 +5,162 @@ from sqlalchemy import or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select -from sqlalchemy.orm import selectinload +from sqlalchemy.orm import joinedload, selectinload from backlog_app._helpers.slug_helper import generate_unique_slug from backlog_app.models import User from backlog_app.models.movie import Movie +from backlog_app.models.user_movie import UserMovie from backlog_app.schemas.movie import MovieCreate, MovieList, MovieRead, MovieUpdate +from backlog_app.schemas.user import UserRead logger = logging.getLogger(__name__) +def build_movie_read( + movie: Movie, + user_movie: UserMovie | None, + joined_by: list[UserRead] | None = None, +) -> MovieRead: + watched = user_movie.watched if user_movie else False + note = user_movie.note if user_movie else None + rating = user_movie.rating if user_movie else None + is_joined = user_movie is not None and user_movie.user_id != movie.user_id + return MovieRead.model_validate( + { + **{c.name: getattr(movie, c.name) for c in movie.__table__.columns}, + "user": movie.user, + "watched": watched, + "note": note, + "rating": rating, + "is_joined": is_joined, + "joined_by": joined_by or [], + } + ) + + +async def create_user_movie( + db: AsyncSession, + user_id: UUID, + movie_id: int, + watched: bool = False, + note: str | None = None, + rating: float | None = None, +) -> UserMovie: + result = await db.execute( + select(UserMovie).where( + UserMovie.user_id == user_id, + UserMovie.movie_id == movie_id, + ) + ) + existing = result.scalars().first() + if existing: + return existing + + user_movie = UserMovie( + user_id=user_id, + movie_id=movie_id, + watched=watched, + note=note, + rating=rating, + ) + db.add(user_movie) + await db.flush() + return user_movie + + +async def delete_user_movie( + db: AsyncSession, + user_id: UUID, + movie_id: int, +) -> None: + result = await db.execute( + select(UserMovie).where( + UserMovie.user_id == user_id, + UserMovie.movie_id == movie_id, + ) + ) + user_movie = result.scalars().first() + if not user_movie: + raise HTTPException(status_code=404, detail="Not joined to this movie") + + await db.delete(user_movie) + await db.flush() + + async def create_movie( db: AsyncSession, movie_in: MovieCreate, user: User ) -> MovieRead: + movie_fields = movie_in.model_dump(exclude={"watched", "note", "rating"}) slug = await generate_unique_slug(db=db, title=movie_in.title) - movie = Movie(**movie_in.model_dump(), slug=slug, user_id=user.id) + movie = Movie(**movie_fields, slug=slug, user_id=user.id) db.add(movie) + await db.flush() # get movie.id before creating UserMovie + + user_movie = await create_user_movie( + db, + user.id, + movie.id, + watched=movie_in.watched or False, + note=movie_in.note, + rating=movie_in.rating, + ) await db.commit() await db.refresh(movie) + await db.refresh(user_movie) + movie.user = user # relationship not loaded by refresh; set explicitly logger.info("Movie <%s> has been created.", movie.slug) - return MovieRead.model_validate(movie) + return build_movie_read(movie, user_movie) -async def get_movies(db: AsyncSession, user_id: str | None = None) -> MovieList: - query = select(Movie).options(selectinload(Movie.user)) - - if user_id: - query = query.where(Movie.user_id == user_id) +async def get_movies( + db: AsyncSession, + filter_user_id: UUID | None = None, + current_user_id: UUID | None = None, +) -> MovieList: + query = select(Movie).options(joinedload(Movie.user)) + + if filter_user_id is not None: + # Return movies where user is creator OR has joined via UserMovie + query = query.where( + or_( + Movie.user_id == filter_user_id, + Movie.id.in_( + select(UserMovie.movie_id).where( + UserMovie.user_id == filter_user_id + ) + ), + ) + ) else: - query = query.where(Movie.published.is_(True)) + query = query.where( + or_(Movie.published.is_(True), Movie.user_id == current_user_id) + ) result = await db.execute(query) movies = result.scalars().all() logger.debug("Size of movies list: %s", len(movies)) - return MovieList.model_validate({"movies": movies}) + # Batch-load UserMovies for current user to avoid N+1 + user_movie_map: dict[int, UserMovie] = {} + if current_user_id is not None and movies: + um_result = await db.execute( + select(UserMovie).where( + UserMovie.user_id == current_user_id, + UserMovie.movie_id.in_([m.id for m in movies]), + ) + ) + user_movie_map = {um.movie_id: um for um in um_result.scalars().all()} + + movie_reads = [ + build_movie_read(movie, user_movie_map.get(movie.id), joined_by=[]) + for movie in movies + ] + + return MovieList.model_validate({"movies": movie_reads}) async def get_random_movie_pool( @@ -53,13 +171,18 @@ async def get_random_movie_pool( if exclude_ids is None: exclude_ids = [] + # Return published or owned movies where the user hasn't watched them yet query = ( select(Movie) - .options(selectinload(Movie.user)) .where( or_(Movie.published.is_(True), Movie.user_id == user_id), - Movie.watched.is_(False), ) + .join( + UserMovie, + (UserMovie.movie_id == Movie.id) & (UserMovie.user_id == user_id), + isouter=True, + ) + .where(or_(UserMovie.watched.is_(False), UserMovie.watched.is_(None))) ) if exclude_ids: @@ -76,22 +199,45 @@ async def get_random_movie_pool( async def get_movie_by_slug( - db: AsyncSession, slug: str, user_id: UUID | None = None -) -> MovieRead | None: - query = select(Movie).options(selectinload(Movie.user)).where(Movie.slug == slug) - - if user_id is not None: - query = query.where(Movie.user_id == user_id) + db: AsyncSession, + slug: str, + current_user_id: UUID, +) -> MovieRead: + query = ( + select(Movie) + .options( + joinedload(Movie.user), + selectinload(Movie.joined_by_users).selectinload(UserMovie.user), + ) + .where(Movie.slug == slug) + .execution_options(populate_existing=True) + ) result = await db.execute(query) - movie = result.scalars().first() + movie = result.scalars().unique().first() if not movie: raise HTTPException(status_code=404, detail="Movie not found") logger.info("Movie has been found.") - return MovieRead.model_validate(movie) + # Find current user's UserMovie for personal state + um_result = await db.execute( + select(UserMovie).where( + UserMovie.user_id == current_user_id, + UserMovie.movie_id == movie.id, + ) + ) + user_movie: UserMovie | None = um_result.scalars().first() + + # Build joined_by: all users who joined, excluding the owner + joined_by: list[UserRead] = [ + UserRead.model_validate(um.user) + for um in movie.joined_by_users + if um.user_id != movie.user_id + ] + + return build_movie_read(movie, user_movie, joined_by=joined_by) async def update_movie( @@ -101,7 +247,11 @@ async def update_movie( user: User, ) -> MovieRead: result = await db.execute( - select(Movie).options(selectinload(Movie.user)).where(Movie.slug == slug) + select(Movie) + .options( + selectinload(Movie.joined_by_users).selectinload(UserMovie.user), + ) + .where(Movie.slug == slug) ) movie = result.scalars().first() @@ -110,15 +260,31 @@ async def update_movie( check_movie_ownership(movie, user) - for field, value in movie_in.model_dump(exclude_unset=True).items(): + # PUT is owner-only: split fields + movie_fields = { + k: v + for k, v in movie_in.model_dump(exclude_unset=True).items() + if k not in {"watched", "note", "rating"} + } + personal_fields = { + k: v + for k, v in movie_in.model_dump(exclude_unset=True).items() + if k in {"watched", "note", "rating"} + } + + for field, value in movie_fields.items(): setattr(movie, field, value) + if personal_fields: + user_movie = await create_user_movie(db, user.id, movie.id) + for field, value in personal_fields.items(): + setattr(user_movie, field, value) + await db.commit() - await db.refresh(movie) logger.info("Movie has been updated.") - return MovieRead.model_validate(movie) + return await get_movie_by_slug(db, slug, user.id) async def partial_update_movie( @@ -126,27 +292,78 @@ async def partial_update_movie( slug: str, movie_in: MovieUpdate, user: User, -) -> Movie | None: - result = await db.execute(select(Movie).where(Movie.slug == slug)) +) -> MovieRead: + result = await db.execute( + select(Movie) + .options( + selectinload(Movie.joined_by_users).selectinload(UserMovie.user), + ) + .where(Movie.slug == slug) + ) movie = result.scalars().first() + if not movie: - raise HTTPException(status_code=404) + raise HTTPException(status_code=404, detail="Movie not found") - check_movie_ownership(movie, user) + is_owner = movie.user_id == user.id or user.is_superuser - for field, value in movie_in.model_dump(exclude_unset=True).items(): - setattr(movie, field, value) + # Non-owners cannot modify protected fields + protected_fields = {"title", "description", "year"} + submitted = set(movie_in.model_dump(exclude_unset=True).keys()) + if not is_owner and submitted & protected_fields: + raise HTTPException( + status_code=403, + detail="Only the owner can edit title, description, or year", + ) + + movie_fields = { + k: v + for k, v in movie_in.model_dump(exclude_unset=True).items() + if k not in {"watched", "note", "rating"} + } + personal_fields = { + k: v + for k, v in movie_in.model_dump(exclude_unset=True).items() + if k in {"watched", "note", "rating"} + } + + # Only owner/superuser can update movie-level fields + if movie_fields and is_owner: + for field, value in movie_fields.items(): + setattr(movie, field, value) + + # Update personal state — owner always has UserMovie; non-owner must have already joined + if personal_fields: + if is_owner: + user_movie = await create_user_movie(db, user.id, movie.id) + else: + um_result = await db.execute( + select(UserMovie).where( + UserMovie.user_id == user.id, + UserMovie.movie_id == movie.id, + ) + ) + user_movie = um_result.scalars().first() + if not user_movie: + raise HTTPException( + status_code=403, + detail="You must join this movie before updating your personal state", + ) + for field, value in personal_fields.items(): + setattr(user_movie, field, value) await db.commit() - await db.refresh(movie) - return movie + + logger.info("Movie has been partially updated.") + + return await get_movie_by_slug(db, slug, user.id) async def delete_movie( db: AsyncSession, slug: str, user: User, -) -> MovieRead: +) -> None: result = await db.execute(select(Movie).where(Movie.slug == slug)) movie = result.scalars().first() if not movie: @@ -159,11 +376,8 @@ async def delete_movie( logger.info("Movie <%s> has been deleted.", slug) - return MovieRead.model_validate(movie) - def check_movie_ownership(movie: Movie, user: User) -> None: - """Проверяет, может ли пользователь изменять фильм""" logger.debug("Checking movie ownership for user %s", user.id) if not user.is_superuser and movie.user_id != user.id: raise HTTPException( diff --git a/backend/backlog_app/api/view/movie_view.py b/backend/backlog_app/api/view/movie_view.py index ebb1221..cf8c6e1 100644 --- a/backend/backlog_app/api/view/movie_view.py +++ b/backend/backlog_app/api/view/movie_view.py @@ -3,11 +3,14 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select from backlog_app.api import crud from backlog_app.dependencies.authentification.fastapi_users_routers import ( current_active_user, ) +from backlog_app.models.movie import Movie +from backlog_app.models.user_movie import UserMovie from backlog_app.models.users import User from backlog_app.schemas.movie import MovieCreate, MovieList, MovieRead, MovieUpdate from backlog_app.storages.database import get_async_session @@ -34,8 +37,10 @@ async def get_movie_list( user: Annotated[User, Depends(current_active_user)], only_mine: bool = False, ): - user_id = user.id if only_mine else None - movies = await crud.get_movies(db, user_id=user_id) + filter_user_id = user.id if only_mine else None + movies = await crud.get_movies( + db, filter_user_id=filter_user_id, current_user_id=user.id + ) return movies @@ -54,7 +59,7 @@ async def get_random_movie( ) chosen = stdlib_random.choice(pool) - return chosen + return await crud.get_movie_by_slug(db, chosen.slug, user.id) @router.get("/{slug}", response_model=MovieRead) @@ -62,11 +67,67 @@ async def get_movie_by_slug( slug: str, db: Annotated[AsyncSession, Depends(get_async_session)], user: Annotated[User, Depends(current_active_user)], - only_mine: bool = False, ): - user_id = user.id if only_mine else None - one_movie = await crud.get_movie_by_slug(db, slug, user_id) - return one_movie + return await crud.get_movie_by_slug(db, slug, user.id) + + +@router.post("/{slug}/join", response_model=MovieRead) +async def join_movie( + slug: str, + db: Annotated[AsyncSession, Depends(get_async_session)], + user: Annotated[User, Depends(current_active_user)], +): + # 1. Find movie (no user_id filter) + result = await db.execute(select(Movie).where(Movie.slug == slug)) + movie = result.scalars().first() + if not movie: + raise HTTPException(status_code=404, detail="Movie not found") + + # 2. Must be published to join + if not movie.published: + raise HTTPException(status_code=403, detail="Cannot join an unpublished movie") + + # 3. Owner cannot join their own movie + if movie.user_id == user.id: + raise HTTPException(status_code=400, detail="You cannot join your own movie") + + # 4. Check if already joined + existing_result = await db.execute( + select(UserMovie).where( + UserMovie.user_id == user.id, + UserMovie.movie_id == movie.id, + ) + ) + if existing_result.scalars().first(): + raise HTTPException(status_code=409, detail="Already joined this movie") + + # 5. Create UserMovie + await crud.create_user_movie(db, user.id, movie.id) + await db.commit() + + # 6. Return updated MovieRead + return await crud.get_movie_by_slug(db, slug, user.id) + + +@router.delete("/{slug}/join", status_code=status.HTTP_204_NO_CONTENT) +async def leave_movie( + slug: str, + db: Annotated[AsyncSession, Depends(get_async_session)], + user: Annotated[User, Depends(current_active_user)], +): + # 1. Find movie + result = await db.execute(select(Movie).where(Movie.slug == slug)) + movie = result.scalars().first() + if not movie: + raise HTTPException(status_code=404, detail="Movie not found") + + # 2. Owner cannot leave their own movie + if movie.user_id == user.id: + raise HTTPException(status_code=400, detail="You cannot leave your own movie") + + # 3. Delete UserMovie (raises 404 if not found) + await crud.delete_user_movie(db, user.id, movie.id) + await db.commit() @router.put("/{slug}", response_model=MovieRead) diff --git a/backend/backlog_app/models/__init__.py b/backend/backlog_app/models/__init__.py index 45765ff..1b35fe8 100644 --- a/backend/backlog_app/models/__init__.py +++ b/backend/backlog_app/models/__init__.py @@ -1,4 +1,5 @@ from .access_token import AccessToken from .base import Base from .movie import Movie +from .user_movie import UserMovie from .users import User diff --git a/backend/backlog_app/models/movie.py b/backend/backlog_app/models/movie.py index 93cc389..882ea42 100644 --- a/backend/backlog_app/models/movie.py +++ b/backend/backlog_app/models/movie.py @@ -2,13 +2,14 @@ from datetime import datetime from typing import TYPE_CHECKING +from fastapi_users_db_sqlalchemy.generics import GUID from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func -from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column, relationship from .base import Base if TYPE_CHECKING: + from .user_movie import UserMovie from .users import User @@ -38,32 +39,17 @@ class Movie(Base): nullable=True, ) - note: Mapped[str | None] = mapped_column( - String(50), - nullable=True, - ) - year: Mapped[int | None] = mapped_column( Integer, nullable=True, ) - watched: Mapped[bool] = mapped_column( - Boolean, - default=False, - server_default="false", - nullable=False, - ) published: Mapped[bool] = mapped_column( Boolean, default=False, server_default="false", nullable=False, ) - rating: Mapped[float | None] = mapped_column( - Float, - nullable=True, - ) imdb_rating: Mapped[float | None] = mapped_column( Float, @@ -87,6 +73,12 @@ class Movie(Base): ) user_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("user.id"), nullable=False + GUID, ForeignKey("user.id"), nullable=False ) user: Mapped["User"] = relationship("User", back_populates="movies", lazy="joined") + joined_by_users: Mapped[list["UserMovie"]] = relationship( + "UserMovie", + back_populates="movie", + cascade="all, delete-orphan", + passive_deletes=True, + ) diff --git a/backend/backlog_app/models/user_movie.py b/backend/backlog_app/models/user_movie.py new file mode 100644 index 0000000..4ecd36f --- /dev/null +++ b/backend/backlog_app/models/user_movie.py @@ -0,0 +1,46 @@ +import uuid +from datetime import datetime +from typing import TYPE_CHECKING + +from fastapi_users_db_sqlalchemy.generics import GUID +from sqlalchemy import ( + Boolean, + DateTime, + Float, + ForeignKey, + Integer, + String, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base + +if TYPE_CHECKING: + from .movie import Movie + from .users import User + + +class UserMovie(Base): + __tablename__ = "user_movies" + __table_args__ = (UniqueConstraint("user_id", "movie_id", name="uq_user_movie"),) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[uuid.UUID] = mapped_column( + GUID, ForeignKey("user.id", ondelete="CASCADE"), nullable=False + ) + movie_id: Mapped[int] = mapped_column( + Integer, ForeignKey("movies.id", ondelete="CASCADE"), nullable=False + ) + watched: Mapped[bool] = mapped_column( + Boolean, default=False, server_default="false", nullable=False + ) + note: Mapped[str | None] = mapped_column(String(50), nullable=True) + rating: Mapped[float | None] = mapped_column(Float, nullable=True) + joined_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + user: Mapped["User"] = relationship("User", back_populates="joined_movies") + movie: Mapped["Movie"] = relationship("Movie", back_populates="joined_by_users") diff --git a/backend/backlog_app/models/users.py b/backend/backlog_app/models/users.py index 37a2fb4..5973b7d 100644 --- a/backend/backlog_app/models/users.py +++ b/backend/backlog_app/models/users.py @@ -12,10 +12,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from .movie import Movie + from .user_movie import UserMovie class User(SQLAlchemyBaseUserTableUUID, Base): movies: Mapped[list["Movie"]] = relationship("Movie", back_populates="user") + joined_movies: Mapped[list["UserMovie"]] = relationship( + "UserMovie", back_populates="user" + ) @classmethod def get_db(cls, session: "AsyncSession"): diff --git a/backend/backlog_app/schemas/movie.py b/backend/backlog_app/schemas/movie.py index 8f13d96..dd5dd95 100644 --- a/backend/backlog_app/schemas/movie.py +++ b/backend/backlog_app/schemas/movie.py @@ -11,9 +11,7 @@ class MovieBase(BaseModel): title: Annotated[str, Len(min_length=3, max_length=255)] description: Annotated[str, Len(min_length=20, max_length=1000)] - note: Annotated[str, Len(min_length=2, max_length=50)] year: int - rating: float watch_link: str | None = None imdb_rating: float | None = None metacritic_score: float | None = None @@ -28,17 +26,18 @@ class MovieBase(BaseModel): class MovieCreate(MovieBase): description: Annotated[str, Len(min_length=20, max_length=1000)] | None = None - note: Annotated[str, Len(min_length=2, max_length=50)] | None = None year: int | None = None + watched: bool = False + note: Annotated[str, Len(min_length=2, max_length=50)] | None = None rating: float | None = Field(default=None, ge=1.0, le=10.0) class MovieUpdate(MovieBase): title: Annotated[str, Len(min_length=3, max_length=255)] | None = None description: Annotated[str, Len(min_length=20, max_length=1000)] | None = None - note: Annotated[str, Len(min_length=2, max_length=50)] | None = None year: int | None = None watched: bool | None = None + note: Annotated[str, Len(min_length=2, max_length=50)] | None = None rating: float | None = Field(default=None, ge=1.0, le=10.0) @@ -46,11 +45,13 @@ class MovieRead(MovieBase): slug: str user: UserRead description: Annotated[str, Len(min_length=20, max_length=1000)] | None - note: Annotated[str, Len(min_length=2, max_length=50)] | None = None year: int | None - watched: bool - rating: float | None + watched: bool = False + note: str | None = None + rating: float | None = None created_at: datetime + is_joined: bool = False + joined_by: list[UserRead] = [] class MovieList(BaseModel): diff --git a/backend/backlog_app/tasks/movie_task.py b/backend/backlog_app/tasks/movie_task.py index 2d11731..d24fd01 100644 --- a/backend/backlog_app/tasks/movie_task.py +++ b/backend/backlog_app/tasks/movie_task.py @@ -4,7 +4,6 @@ from sqlalchemy import select from backlog_app.api import crud -from backlog_app.api.crud import partial_update_movie from backlog_app.config import settings from backlog_app.models.users import User as UserModel from backlog_app.schemas.movie import MovieUpdate @@ -26,6 +25,7 @@ async def update_movie_metadata( movie = await crud.get_movie_by_slug( db, movie_slug, + user_id, ) user_result = await db.execute(select(UserModel).where(UserModel.id == user_id)) @@ -35,13 +35,6 @@ async def update_movie_metadata( logger.warning("User <%s> not found", user_id) return - if movie is None: - logger.warning( - "Movie <%s> not found", - movie_slug, - ) - return - if not movie.year: logger.info( "Movie <%s> has no year, skipping metadata update", @@ -84,7 +77,7 @@ async def update_movie_metadata( if not update_data: return - await partial_update_movie( + await crud.partial_update_movie( db, movie_slug, MovieUpdate(**update_data), diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d199685..138ea31 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,9 +1,10 @@ import contextlib import os -from typing import Any, AsyncGenerator, Generator +from typing import AsyncGenerator import pytest from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.future import select from backlog_app._helpers.create_super_user import create_user from backlog_app.api.crud import create_movie, delete_movie @@ -78,12 +79,52 @@ def build_movie_create( @pytest.fixture async def movie(session, user_test) -> AsyncGenerator[MovieRead, None]: - title = "Interstellar" - description = "Interstellar" * 20 - rating = 9.5 - watch_link = "https://example.com" - movie_in = build_movie_create(title, rating, watch_link, description) - + movie_in = build_movie_create( + "Interstellar", 9.5, "https://example.com", "Interstellar" * 20 + ) movie = await create_movie(session, movie_in, user_test) yield movie - await delete_movie(session, movie.id, user_test) + try: + await delete_movie(session, movie.slug, user_test) + except Exception: + pass + + +async def _make_user(session, email: str) -> AsyncGenerator[User, None]: + get_user_db_context = contextlib.asynccontextmanager(lambda: get_user_db(session)) + get_user_manager_context = contextlib.asynccontextmanager(get_user_manager) + user_create = UserCreate( + email=email, + password="testpassword", + is_active=True, + is_superuser=False, + is_verified=True, + ) + async with get_user_db_context() as user_db: + async with get_user_manager_context(user_db) as user_manager: + user = await create_user(user_manager=user_manager, user_create=user_create) + yield user + await user_manager.delete(user) + + +@pytest.fixture +async def user_other(session) -> AsyncGenerator[User, None]: + async for user in _make_user(session, "other_user@test.com"): + yield user + + +@pytest.fixture +async def published_movie(session, user_test) -> AsyncGenerator[Movie, None]: + movie_in = MovieCreate( + title="Published Test Movie", + description="A movie for testing many-to-many features.", + published=True, + ) + movie_read = await create_movie(session, movie_in, user_test) + result = await session.execute(select(Movie).where(Movie.slug == movie_read.slug)) + movie = result.scalars().first() + yield movie + try: + await delete_movie(session, movie.slug, user_test) + except Exception: + pass diff --git a/backend/tests/test_many_to_many.py b/backend/tests/test_many_to_many.py new file mode 100644 index 0000000..e8fbb45 --- /dev/null +++ b/backend/tests/test_many_to_many.py @@ -0,0 +1,336 @@ +"""Tests for many-to-many movie sharing functionality.""" + +import pytest +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backlog_app.api import crud +from backlog_app.models import Movie, User +from backlog_app.models.user_movie import UserMovie +from backlog_app.schemas.movie import MovieCreate, MovieUpdate + +# ── create_movie: owner UserMovie is auto-created ──────────────────────────── + + +async def test_create_movie_auto_creates_owner_user_movie( + session: AsyncSession, + user_test: User, +) -> None: + movie_in = MovieCreate(title="Auto UserMovie Test", description="A" * 20) + movie_read = await crud.create_movie(session, movie_in, user_test) + + result = await session.execute(select(Movie).where(Movie.slug == movie_read.slug)) + movie = result.scalars().first() + assert movie is not None + + um_result = await session.execute( + select(UserMovie).where( + UserMovie.user_id == user_test.id, + UserMovie.movie_id == movie.id, + ) + ) + user_movie = um_result.scalars().first() + assert user_movie is not None + assert user_movie.user_id == user_test.id + assert user_movie.movie_id == movie.id + + await crud.delete_movie(session, movie_read.slug, user_test) + + +# ── create_user_movie ───────────────────────────────────────────────────────── + + +async def test_create_user_movie( + session: AsyncSession, + user_other: User, + published_movie: Movie, +) -> None: + user_movie = await crud.create_user_movie( + session, user_other.id, published_movie.id + ) + + assert user_movie.user_id == user_other.id + assert user_movie.movie_id == published_movie.id + assert user_movie.watched is False + assert user_movie.note is None + assert user_movie.rating is None + + await crud.delete_user_movie(session, user_other.id, published_movie.id) + + +async def test_create_user_movie_is_idempotent( + session: AsyncSession, + user_other: User, + published_movie: Movie, +) -> None: + um1 = await crud.create_user_movie(session, user_other.id, published_movie.id) + um2 = await crud.create_user_movie(session, user_other.id, published_movie.id) + assert um1.id == um2.id + + await crud.delete_user_movie(session, user_other.id, published_movie.id) + + +# ── delete_user_movie ───────────────────────────────────────────────────────── + + +async def test_delete_user_movie( + session: AsyncSession, + user_other: User, + published_movie: Movie, +) -> None: + await crud.create_user_movie(session, user_other.id, published_movie.id) + await crud.delete_user_movie(session, user_other.id, published_movie.id) + + um_result = await session.execute( + select(UserMovie).where( + UserMovie.user_id == user_other.id, + UserMovie.movie_id == published_movie.id, + ) + ) + assert um_result.scalars().first() is None + + +async def test_delete_user_movie_not_joined_raises_404( + session: AsyncSession, + user_other: User, + published_movie: Movie, +) -> None: + with pytest.raises(HTTPException) as exc_info: + await crud.delete_user_movie(session, user_other.id, published_movie.id) + assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND + + +# ── is_joined flag via get_movie_by_slug ────────────────────────────────────── + + +async def test_is_joined_false_for_owner( + session: AsyncSession, + user_test: User, + published_movie: Movie, +) -> None: + movie_read = await crud.get_movie_by_slug( + session, published_movie.slug, user_test.id + ) + assert movie_read.is_joined is False + + +async def test_is_joined_true_for_joined_user( + session: AsyncSession, + user_other: User, + published_movie: Movie, +) -> None: + await crud.create_user_movie(session, user_other.id, published_movie.id) + + movie_read = await crud.get_movie_by_slug( + session, published_movie.slug, user_other.id + ) + assert movie_read.is_joined is True + + await crud.delete_user_movie(session, user_other.id, published_movie.id) + + +async def test_is_joined_false_for_stranger( + session: AsyncSession, + user_other: User, + published_movie: Movie, +) -> None: + movie_read = await crud.get_movie_by_slug( + session, published_movie.slug, user_other.id + ) + assert movie_read.is_joined is False + + +# ── partial_update_movie: owner permissions ─────────────────────────────────── + + +async def test_owner_can_update_title( + session: AsyncSession, + user_test: User, + published_movie: Movie, +) -> None: + updated = await crud.partial_update_movie( + session, published_movie.slug, MovieUpdate(title="Updated Title"), user_test + ) + assert updated.title == "Updated Title" + + +async def test_non_owner_cannot_update_title( + session: AsyncSession, + user_other: User, + published_movie: Movie, +) -> None: + with pytest.raises(HTTPException) as exc_info: + await crud.partial_update_movie( + session, published_movie.slug, MovieUpdate(title="Hacked Title"), user_other + ) + assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN + + +# ── partial_update_movie: joined user personal fields ──────────────────────── + + +async def test_joined_user_can_update_watched( + session: AsyncSession, + user_other: User, + published_movie: Movie, +) -> None: + await crud.create_user_movie(session, user_other.id, published_movie.id) + + updated = await crud.partial_update_movie( + session, published_movie.slug, MovieUpdate(watched=True), user_other + ) + assert updated.watched is True + + await crud.delete_user_movie(session, user_other.id, published_movie.id) + + +async def test_joined_user_can_update_note( + session: AsyncSession, + user_other: User, + published_movie: Movie, +) -> None: + await crud.create_user_movie(session, user_other.id, published_movie.id) + + updated = await crud.partial_update_movie( + session, published_movie.slug, MovieUpdate(note="My note"), user_other + ) + assert updated.note == "My note" + + await crud.delete_user_movie(session, user_other.id, published_movie.id) + + +async def test_joined_user_can_update_rating( + session: AsyncSession, + user_other: User, + published_movie: Movie, +) -> None: + await crud.create_user_movie(session, user_other.id, published_movie.id) + + updated = await crud.partial_update_movie( + session, published_movie.slug, MovieUpdate(rating=7.5), user_other + ) + assert updated.rating == 7.5 + + await crud.delete_user_movie(session, user_other.id, published_movie.id) + + +async def test_non_joined_user_cannot_update_personal_fields( + session: AsyncSession, + user_other: User, + published_movie: Movie, +) -> None: + with pytest.raises(HTTPException) as exc_info: + await crud.partial_update_movie( + session, published_movie.slug, MovieUpdate(watched=True), user_other + ) + assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN + + +# ── personal state isolation ────────────────────────────────────────────────── + + +async def test_personal_state_isolation( + session: AsyncSession, + user_test: User, + user_other: User, + published_movie: Movie, +) -> None: + """watched и note хранятся отдельно для каждого пользователя.""" + await crud.partial_update_movie( + session, + published_movie.slug, + MovieUpdate(watched=True, note="Owner note"), + user_test, + ) + + await crud.create_user_movie(session, user_other.id, published_movie.id) + await crud.partial_update_movie( + session, + published_movie.slug, + MovieUpdate(note="Other note"), + user_other, + ) + + owner_view = await crud.get_movie_by_slug( + session, published_movie.slug, user_test.id + ) + other_view = await crud.get_movie_by_slug( + session, published_movie.slug, user_other.id + ) + + assert owner_view.watched is True + assert owner_view.note == "Owner note" + assert other_view.watched is False + assert other_view.note == "Other note" + + await crud.delete_user_movie(session, user_other.id, published_movie.id) + + +# ── get_movies: is_joined in list ───────────────────────────────────────────── + + +async def test_get_movies_is_joined_true_for_joined_user( + session: AsyncSession, + user_other: User, + published_movie: Movie, +) -> None: + await crud.create_user_movie(session, user_other.id, published_movie.id) + + movie_list = await crud.get_movies( + session, + filter_user_id=user_other.id, + current_user_id=user_other.id, + ) + + joined = next( + (m for m in movie_list.movies if m.slug == published_movie.slug), None + ) + assert joined is not None + assert joined.is_joined is True + + await crud.delete_user_movie(session, user_other.id, published_movie.id) + + +async def test_get_movies_is_joined_false_for_owner( + session: AsyncSession, + user_test: User, + published_movie: Movie, +) -> None: + movie_list = await crud.get_movies( + session, + filter_user_id=user_test.id, + current_user_id=user_test.id, + ) + + owner_movie = next( + (m for m in movie_list.movies if m.slug == published_movie.slug), None + ) + assert owner_movie is not None + assert owner_movie.is_joined is False + + +async def test_get_movies_current_user_id_populates_watched( + session: AsyncSession, + user_other: User, + published_movie: Movie, +) -> None: + """current_user_id всегда загружает личное состояние, даже без filter_user_id.""" + await crud.create_user_movie(session, user_other.id, published_movie.id) + await crud.partial_update_movie( + session, published_movie.slug, MovieUpdate(watched=True), user_other + ) + + # public list (filter_user_id=None), but current_user_id set + movie_list = await crud.get_movies( + session, + filter_user_id=None, + current_user_id=user_other.id, + ) + + movie = next((m for m in movie_list.movies if m.slug == published_movie.slug), None) + assert movie is not None + assert movie.watched is True + assert movie.is_joined is True + + await crud.delete_user_movie(session, user_other.id, published_movie.id) diff --git a/frontend/src/api/movies.ts b/frontend/src/api/movies.ts index 532744d..2d8e272 100644 --- a/frontend/src/api/movies.ts +++ b/frontend/src/api/movies.ts @@ -33,6 +33,8 @@ export interface MovieRead { user: UserRead watched: boolean createdAt: string + isJoined: boolean + joinedBy: UserRead[] } export interface MovieList { @@ -47,12 +49,10 @@ export const moviesApi = { return data }, - async getBySlug(slug: string, onlyMine = false): Promise { - const { data } = await api.get(`/movies/${slug}`, { - params: { only_mine: onlyMine }, - }) - return data -}, + async getBySlug(slug: string): Promise { + const { data } = await api.get(`/movies/${slug}`) + return data + }, async getRandom(excludeSlugs: string[] = []): Promise { const params = new URLSearchParams() @@ -74,4 +74,13 @@ export const moviesApi = { async delete(slug: string): Promise { await api.delete(`/movies/${slug}`) }, + + async joinMovie(slug: string): Promise { + const { data } = await api.post(`/movies/${slug}/join`) + return data + }, + + async leaveMovie(slug: string): Promise { + await api.delete(`/movies/${slug}/join`) + }, } diff --git a/frontend/src/components/ui/MovieCard.vue b/frontend/src/components/ui/MovieCard.vue index 027f695..489f6db 100644 --- a/frontend/src/components/ui/MovieCard.vue +++ b/frontend/src/components/ui/MovieCard.vue @@ -7,12 +7,12 @@ 'flex items-center gap-1.5 text-xs font-mono transition-all duration-150', movie.watched ? 'text-emerald-600' - : isOwner + : (isOwner || movie.isJoined) ? 'text-base-300 hover:text-base-600 cursor-pointer' : 'text-base-200 cursor-not-allowed opacity-60', ]" - :disabled="!isOwner" - :title="!isOwner ? 'Только автор может изменять статус' : movie.watched ? 'Снять отметку' : 'Отметить как просмотренный'" + :disabled="!isOwner && !movie.isJoined" + :title="(!isOwner && !movie.isJoined) ? 'Только автор или участник может изменять статус' : movie.watched ? 'Снять отметку' : 'Отметить как просмотренный'" @click.stop="handleToggleWatched" > @@ -193,7 +193,7 @@ const isOwner = computed(() => ) function handleToggleWatched() { - if (!isOwner.value) { + if (!isOwner.value && !props.movie.isJoined) { toast.error('Вы не можете изменять чужие записи') return } diff --git a/frontend/src/stores/movies.ts b/frontend/src/stores/movies.ts index f60671f..c0c7ee4 100644 --- a/frontend/src/stores/movies.ts +++ b/frontend/src/stores/movies.ts @@ -42,6 +42,27 @@ export const useMoviesStore = defineStore('movies', () => { return updateMovie(movie.slug, { watched: !movie.watched }) } + async function joinMovie(slug: string): Promise { + const updated = await moviesApi.joinMovie(slug) + const index = movies.value.findIndex((m) => m.slug === slug) + if (index !== -1) movies.value[index] = updated + return updated + } + + async function leaveMovie(slug: string): Promise { + await moviesApi.leaveMovie(slug) + const index = movies.value.findIndex((m) => m.slug === slug) + if (index !== -1) { + movies.value[index] = { + ...movies.value[index], + isJoined: false, + watched: false, + note: null, + rating: null, + } + } + } + return { movies, loading, @@ -51,5 +72,7 @@ export const useMoviesStore = defineStore('movies', () => { updateMovie, deleteMovie, toggleWatched, + joinMovie, + leaveMovie, } }) diff --git a/frontend/src/views/movies/MovieDetailView.vue b/frontend/src/views/movies/MovieDetailView.vue index 1bfe6b6..196e028 100644 --- a/frontend/src/views/movies/MovieDetailView.vue +++ b/frontend/src/views/movies/MovieDetailView.vue @@ -133,7 +133,24 @@ - + +
+

Смотрят также

+
+ + + + + {{ u.username || u.email }} + +
+
+ +
- +