From 31a74c4b53a9b1a90b7ff80db49e4f708b88ddb4 Mon Sep 17 00:00:00 2001 From: Bondzik-S Date: Mon, 3 Feb 2025 23:23:47 +0200 Subject: [PATCH 1/6] feat: update ruff ignore --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 750fea3..c0d15e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,6 +72,7 @@ ignore = [ "N802", "B008", "F821", + "I001", ] [tool.ruff.lint.pydocstyle] From 50dfd7facaefae50328cc0b220698318b8840337 Mon Sep 17 00:00:00 2001 From: Bondzik-S Date: Mon, 3 Feb 2025 23:24:56 +0200 Subject: [PATCH 2/6] feat: add crud --- src/database/crud/movies.py | 193 ++++++++++ src/database/models/movies.py | 53 +++ src/routes/movies.py | 649 ++++++++++++++++++++++++++++++++++ src/schemas/movies.py | 153 ++++++++ 4 files changed, 1048 insertions(+) create mode 100644 src/database/crud/movies.py create mode 100644 src/routes/movies.py create mode 100644 src/schemas/movies.py diff --git a/src/database/crud/movies.py b/src/database/crud/movies.py new file mode 100644 index 0000000..681917e --- /dev/null +++ b/src/database/crud/movies.py @@ -0,0 +1,193 @@ +from typing import List, Optional, Type, cast + +from sqlalchemy.orm import Session, joinedload + +from database import Certification, Director, Genre, Movie, Star, User +from database.models.movies import FavoriteMovie, MovieLike +from schemas.movies import GenresSchema, MovieCreateSchema, MovieSortEnum, StarsSchema + + +def get_movies_paginated(page: int, per_page: int,db: Session) -> [int, list[Movie]]: + offset = (page - 1) * per_page + + query = db.query(Movie).order_by() + + order_by = Movie.default_order_by() + if order_by: + query = query.order_by(*order_by) + + total_items = query.count() + movies = query.offset(offset).limit(per_page).all() + + return total_items, movies + + +def filter_movies( + db: Session, + filters: dict[str, str], + sort_by: Optional[MovieSortEnum] = None +) -> list[Movie]: + query = db.query(Movie) + + if filters.get("name"): + query = query.filter(Movie.name.ilike(f"%{filters['name']}%")) + if filters.get("year"): + query = query.filter(Movie.year == filters["year"]) + if filters.get("min_imdb"): + query = query.filter(Movie.imdb >= filters["min_imdb"]) + if filters.get("max_imdb"): + query = query.filter(Movie.imdb <= filters["max_imdb"]) + if filters.get("min_votes"): + query = query.filter(Movie.votes >= filters["min_votes"]) + if filters.get("max_votes"): + query = query.filter(Movie.votes <= filters["max_votes"]) + if filters.get("min_price"): + query = query.filter(Movie.price >= filters["min_price"]) + if filters.get("max_price"): + query = query.filter(Movie.price <= filters["max_price"]) + + if sort_by: + if sort_by == MovieSortEnum.PRICE_ASC: + query = query.order_by(Movie.price) + elif sort_by == MovieSortEnum.PRICE_DESC: + query = query.order_by(Movie.price.desc()) + elif sort_by == MovieSortEnum.RELEASE_YEAR_ASC: + query = query.order_by(Movie.year) + elif sort_by == MovieSortEnum.RELEASE_YEAR_DESC: + query = query.order_by(Movie.year.desc()) + elif sort_by == MovieSortEnum.VOTES_ASC: + query = query.order_by(Movie.votes) + elif sort_by == MovieSortEnum.VOTES_DESC: + query = query.order_by(Movie.votes.desc()) + elif sort_by == MovieSortEnum.IMDb_ASC: + query = query.order_by(Movie.imdb) + elif sort_by == MovieSortEnum.IMDb_DESC: + query = query.order_by(Movie.imdb.desc()) + + return cast(List[Movie], query.all()) + +def get_detail_movies_by_id(db: Session, movie_id: int) -> Movie | None: + return ( + db.query(Movie) + .options( + joinedload(Movie.certification), + joinedload(Movie.genres), + joinedload(Movie.stars), + joinedload(Movie.directors), + ) + .filter(Movie.id == movie_id) + .first() + ) + +def get_movie_by_id(db: Session, movie_id: int) -> Movie | None: + return db.query(Movie).filter(Movie.id == movie_id).first() + + +def get_movie_by_name(db: Session, movie_data: MovieCreateSchema) -> Movie | None: + return ( + db.query(Movie).filter( + Movie.name == movie_data.name + ).first() + ) + +def get_certification_by_name( + db: Session, + movie_data: MovieCreateSchema +) -> Certification | None: + return db.query(Certification).filter_by(name=movie_data.certification).first() + +def get_or_create_certification( + db: Session, + movie_data: MovieCreateSchema +) -> Certification: + certification = get_certification_by_name(db, movie_data) + if not certification: + certification = Certification(name=movie_data.certification) + db.add(certification) + db.commit() + db.refresh(certification) + + return certification + +def get_genre_by_id(db: Session, genre_id: int) -> Genre | None: + return db.query(Genre).filter_by(id=genre_id).first() + +def get_genre_by_name(db: Session, genres_data: GenresSchema) -> Genre | None: + return db.query(Genre).filter_by(name=genres_data.name).first() + +def get_all_genres(db: Session) -> list[Genre]: + return cast(List[Genre], db.query(Genre).all()) + +def get_or_create_genres( + db: Session, + movie_data: MovieCreateSchema +) -> list[Genre | Type[Genre]]: + genres = [] + + for genre_name in movie_data.genres: + genre = db.query(Genre).filter_by(name=genre_name).first() + if not genre: + genre = Genre(name=genre_name) + db.add(genre) + db.flush() + genres.append(genre) + + return genres + +def get_star_by_name(db:Session, stars_data: StarsSchema) -> Star | None: + return db.query(Star).filter_by(name=stars_data.name).first() + +def get_star_by_id(db: Session, star_id: int) -> Star | None: + return db.query(Star).filter_by(id=star_id).first() + +def get_all_stars(db: Session) -> list[Star]: + return cast(List[Star], db.query(Star).all()) + +def get_or_create_stars( + db: Session, + movie_data: MovieCreateSchema +) -> list[Star | Type[Star]]: + stars = [] + + for star_name in movie_data.stars: + star = db.query(Star).filter_by(name=star_name).first() + if not star: + star = Star(name=star_name) + db.add(star) + db.flush() + stars.append(star) + + return stars + +def get_or_create_directors( + db: Session, + movie_data: MovieCreateSchema +) -> list[Director | Type[Director]]: + directors = [] + + for director_name in movie_data.directors: + director = db.query(Director).filter_by(name=director_name).first() + if not director: + director = Director(name=director_name) + db.add(director) + db.flush() + directors.append(director) + + return directors + +def get_user_by_id(db: Session, user_id: int) -> User | None: + return db.query(User).filter_by(id=user_id).first() + +def get_liked_movie(db: Session, movie: Movie, user: User) -> MovieLike | None: + return ( + db.query(MovieLike).filter_by( + movie_id=movie.id, user_id=user.id + ).first() + ) + +def get_favourite_movie(db: Session, movie: Movie, user: User) -> FavoriteMovie | None: + return ( + db.query(FavoriteMovie).filter_by( + movie_id=movie.id, user_id=user.id + ).first() + ) diff --git a/src/database/models/movies.py b/src/database/models/movies.py index da4737e..8d112d6 100644 --- a/src/database/models/movies.py +++ b/src/database/models/movies.py @@ -3,11 +3,14 @@ from sqlalchemy import ( DECIMAL, + TIMESTAMP, + Boolean, Float, ForeignKey, String, Text, UniqueConstraint, + func ) from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -142,6 +145,13 @@ class Movie(Base): order_items: Mapped[list["OrderItem"]] = relationship( "OrderItem", back_populates="movie" ) + likes: Mapped[list["MovieLike"]] = relationship( + "MovieLike", back_populates="movie", cascade="all, delete-orphan" + ) + + favorites: Mapped[list["FavoriteMovie"]] = relationship( + "FavoriteMovie", back_populates="movie", cascade="all, delete-orphan" + ) __table_args__ = ( UniqueConstraint("name", "year", "time", name="unique_movie_constraint"), @@ -149,3 +159,46 @@ class Movie(Base): def __repr__(self) -> str: return f"" + + + +class MovieLike(Base): + __tablename__ = "movie_likes" + + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True) + movie_id: Mapped[int] = mapped_column(ForeignKey("movies.id"), primary_key=True) + is_liked: Mapped[bool] = mapped_column(Boolean, nullable=False) + created_at: Mapped[TIMESTAMP] = mapped_column( + TIMESTAMP, + nullable=False, + server_default=func.now() + ) + + user: Mapped["User"] = relationship("User", back_populates="likes") + movie: Mapped["Movie"] = relationship("Movie", back_populates="likes") + + def __repr__(self) -> str: + return ( + f"" + ) + + +class FavoriteMovie(Base): + __tablename__ = "favorite_movies" + + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True) + movie_id: Mapped[int] = mapped_column(ForeignKey("movies.id"), primary_key=True) + is_favorited: Mapped[bool] = mapped_column(Boolean, nullable=False) + created_at: Mapped[TIMESTAMP] = mapped_column( + TIMESTAMP, + nullable=False, + server_default=func.now() + ) + + user: Mapped["User"] = relationship("User", back_populates="favorites") + movie: Mapped["Movie"] = relationship("Movie", back_populates="favorites") + + def __repr__(self) -> str: + return f"" diff --git a/src/routes/movies.py b/src/routes/movies.py new file mode 100644 index 0000000..405793f --- /dev/null +++ b/src/routes/movies.py @@ -0,0 +1,649 @@ +from typing import Dict, List, Optional, cast, Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from config import get_jwt_auth_manager +from database import Genre, Movie, Star, get_db +from database.crud.movies import ( + filter_movies, + get_all_genres, + get_all_stars, + get_detail_movies_by_id, + get_favourite_movie, + get_genre_by_id, + get_genre_by_name, + get_liked_movie, + get_movie_by_id, + get_movie_by_name, + get_or_create_certification, + get_or_create_directors, + get_or_create_genres, + get_or_create_stars, + get_star_by_id, + get_star_by_name, + get_user_by_id, +) +from database.models.movies import FavoriteMovie, MovieLike +from schemas.movies import ( + GenreResponseSchema, + GenresSchema, + MovieCreateSchema, + MovieDetailSchema, + MovieFavoriteResponseSchema, + MovieLikeResponseSchema, + MovieListItemSchema, + MovieListResponseSchema, + MovieSortEnum, + MovieUpdateSchema, + StarsResponseSchema, + StarsSchema, +) +from security.http import get_token +from security.interfaces import JWTAuthManagerInterface + +router = APIRouter() + + +@router.get( + "/movies/", + response_model=MovieListResponseSchema, + summary="Get a paginated list of movies", + description=( + "

This endpoint retrieves a paginated list of movies from the database. " + "Clients can specify the `page` number and the number of items " + "per page using `per_page`. " + "The response includes details about the movies, " + "total pages, and total items, " + "along with links to the previous and next pages if applicable.

" + ), + responses={ + 404: { + "description": "No movies found.", + "content": { + "application/json": { + "example": {"detail": "No movies found."} + } + }, + } + } +) +def get_movie_list( + page: int = Query(1, ge=1, description="Page number (1-based index)"), + per_page: int = Query(10, ge=1, le=20, description="Number of items per page"), + name: Optional[str] = Query(None), + year: Optional[int] = Query(None), + min_imdb: Optional[float] = Query(None), + max_imdb: Optional[float] = Query(None), + min_votes: Optional[int] = Query(None), + max_votes: Optional[int] = Query(None), + min_price: Optional[float] = Query(None), + max_price: Optional[float] = Query(None), + sort_by: Optional[MovieSortEnum] = Query(None), + db: Session = Depends(get_db), +) -> MovieListResponseSchema: + """ + Fetch a paginated list of movies from the database. + + This function retrieves a paginated list of movies, + allowing the client to specify + the page number and the number of items per page. It calculates the total pages + and provides links to the previous and next pages when applicable. + + :param page: The page number to retrieve (1-based index, must be >= 1). + :type page: int + :param per_page: + The number of items to display per page (must be between 1 and 20). + :type per_page: int + :param db: The SQLAlchemy database session (provided via dependency injection). + :type db: Session + + :return: A response containing the paginated list of movies and metadata. + :rtype: MovieListResponseSchema + + :raises HTTPException: + Raises a 404 error if no movies are found for the requested page. + """ + + movies_filter = { + "name": name, "year": year, "min_imdb": min_imdb, "max_imdb": max_imdb, + "min_votes": min_votes, "max_votes": max_votes, + "min_price": min_price, "max_price": max_price + } + + filtered_movies = filter_movies(db, movies_filter, sort_by) + + total_items = len(filtered_movies) + total_pages = (total_items + per_page - 1) // per_page + + start = (page - 1) * per_page + end = start + per_page + paginated_movies = filtered_movies[start:end] + + if not paginated_movies: + raise HTTPException(status_code=404, detail="No movies found.") + + return MovieListResponseSchema( + movies=[ + MovieListItemSchema.model_validate(movie) for movie in paginated_movies + ], + prev_page=( + f"/movies/?page={page - 1}&per_page={per_page}" + if page > 1 else None + ), + next_page=( + f"/movies/?page={page + 1}&per_page={per_page}" + if page < total_pages else None + ), + total_pages=total_pages, + total_items=total_items, + ) + + +@router.get( + "/movies/{movie_id}/", + response_model=MovieDetailSchema, + summary="Get movie details by ID", + description=( + "

Fetch detailed information about a specific movie by its unique ID. " + "This endpoint retrieves all available details for the movie, such as " + "its name, genre, crew, budget, and revenue. If the movie with the given " + "ID is not found, a 404 error will be returned.

" + ), + responses={ + 404: { + "description": "Movie not found.", + "content": { + "application/json": { + "example": {"detail": "Movie with the given ID was not found."} + } + }, + } + } +) +def movie_detail( + movie_id: int, + db: Session = Depends(get_db), +) -> MovieDetailSchema: + """ + Retrieve detailed information about a specific movie by its ID. + + This function fetches detailed information about a movie + identified by its unique ID. + If the movie does not exist, a 404 error is returned. + + :param movie_id: The unique identifier of the movie to retrieve. + :type movie_id: int + :param db: The SQLAlchemy database session (provided via dependency injection). + :type db: Session + + :return: The details of the requested movie. + :rtype: MovieDetailResponseSchema + + :raises HTTPException: + Raises a 404 error if the movie with the given ID is not found. + """ + movie = get_detail_movies_by_id(db, movie_id) + + if not movie: + raise HTTPException( + status_code=404, + detail="Movie with the given ID was not found." + ) + + return MovieDetailSchema.model_validate(movie) + + +@router.post( + "/movies/", + response_model=MovieDetailSchema, + summary="Add a new movie", + description=( + "

This endpoint allows clients to add a new movie to the database. " + "It accepts details such as name, date, genres, actors, languages, and " + "other attributes. The associated country, genres, actors, and languages " + "will be created or linked automatically.

" + ), + responses={ + 201: { + "description": "Movie created successfully.", + }, + 400: { + "description": "Invalid input.", + "content": { + "application/json": { + "example": {"detail": "Invalid input data."} + } + }, + } + }, + status_code=201 +) +def create_movie( + movie_data: MovieCreateSchema, + db: Session = Depends(get_db) +) -> MovieDetailSchema: + """ + Add a new movie to the database. + + This endpoint allows the creation of a new movie with details such as + name, release date, genres, actors, and languages. It automatically + handles linking or creating related entities. + + :param movie_data: The data required to create a new movie. + :type movie_data: MovieCreateSchema + :param db: The SQLAlchemy database session (provided via dependency injection). + :type db: Session + + :return: The created movie with all details. + :rtype: MovieDetailSchema + + :raises HTTPException: Raises a 400 error for invalid input. + """ + existing_movie = get_movie_by_name(db, movie_data) + + if existing_movie: + raise HTTPException( + status_code=409, + detail=( + f"A movie with the name '{movie_data.name}'" + ) + ) + + try: + certification = get_or_create_certification(db, movie_data) + genres = get_or_create_genres(db, movie_data) + stars = get_or_create_stars(db, movie_data) + directors = get_or_create_directors(db, movie_data) + + movie = Movie( + name=movie_data.name, + year=movie_data.year, + time=movie_data.time, + imdb=movie_data.imdb, + votes=movie_data.votes, + price=movie_data.price, + description=movie_data.description, + certification_id=certification.id, + genres=genres, + stars=stars, + directors=directors, + ) + db.add(movie) + db.commit() + db.refresh(movie) + + return MovieDetailSchema.model_validate(movie) + except HTTPException: + db.rollback() + raise HTTPException(status_code=400, detail="Invalid input data.") + + +@router.patch( + "/movies/{movie_id}/", + summary="Update a movie by ID", + description=( + "

Update details of a specific movie by its unique ID.

" + "

This endpoint updates the details of an existing movie. If the movie with " + "the given ID does not exist, a 404 error is returned.

" + ), + responses={ + 200: { + "description": "Movie updated successfully.", + "content": { + "application/json": { + "example": {"detail": "Movie updated successfully."} + } + }, + }, + 404: { + "description": "Movie not found.", + "content": { + "application/json": { + "example": {"detail": "Movie with the given ID was not found."} + } + }, + }, + } +) +def update_movie( + movie_id: int, + movie_data: MovieUpdateSchema, + db: Session = Depends(get_db), +) -> dict[str, str]: + """ + Update a specific movie by its ID. + + This function updates a movie identified by its unique ID. + If the movie does not exist, a 404 error is raised. + + :param movie_id: The unique identifier of the movie to update. + :type movie_id: int + :param movie_data: The updated data for the movie. + :type movie_data: MovieUpdateSchema + :param db: The SQLAlchemy database session (provided via dependency injection). + :type db: Session + + :raises HTTPException: + Raises a 404 error if the movie with the given ID is not found. + + :return: A response indicating the successful update of the movie. + :rtype: None + """ + movie = get_movie_by_id(db, movie_id) + + if not movie: + raise HTTPException( + status_code=404, + detail="Movie with the given ID was not found." + ) + + for field, value in movie_data.model_dump(exclude_unset=True).items(): + setattr(movie, field, value) + + try: + db.commit() + db.refresh(movie) + except HTTPException: + db.rollback() + raise HTTPException(status_code=400, detail="Invalid input data.") + else: + return {"detail": "Movie updated successfully."} + + +@router.delete( + "/movies/{movie_id}/", + summary="Delete a movie by ID", + description=( + "

Delete a specific movie from the database by its unique ID.

" + "

If the movie exists, it will be deleted. If it does not exist, " + "a 404 error will be returned.

" + ), + responses={ + 204: { + "description": "Movie deleted successfully." + }, + 404: { + "description": "Movie not found.", + "content": { + "application/json": { + "example": {"detail": "Movie with the given ID was not found."} + } + }, + }, + }, + status_code=204 +) +def delete_movie( + movie_id: int, + db: Session = Depends(get_db), +) -> None: + """ + Delete a specific movie by its ID. + + This function deletes a movie identified by its unique ID. + If the movie does not exist, a 404 error is raised. + + :param movie_id: The unique identifier of the movie to delete. + :type movie_id: int + :param db: The SQLAlchemy database session (provided via dependency injection). + :type db: Session + + :raises HTTPException: + Raises a 404 error if the movie with the given ID is not found. + + :return: A response indicating the successful deletion of the movie. + :rtype: None + """ + movie = get_movie_by_id(db, movie_id) + + if not movie: + raise HTTPException( + status_code=404, + detail="Movie with the given ID was not found." + ) + + db.delete(movie) + db.commit() + return + + +@router.post("/stars/") +def create_star( + stars_data: StarsSchema, + db: Session = Depends(get_db), +) -> StarsResponseSchema: + star = get_star_by_name(db, stars_data) + if star: + raise HTTPException( + status_code=400, + detail="Star already exists.", + ) + + star = Star(name=stars_data.name) + db.add(star) + db.commit() + db.refresh(star) + + return StarsResponseSchema.model_validate(star) + + +@router.get("/stars/", response_model=list[StarsResponseSchema]) +def star_list( + db: Session = Depends(get_db), +) -> list[Star]: + return cast(List[Star], get_all_stars(db)) + + +@router.patch("/stars/{star_id}/") +def star_update( + star_id: int, + star_data: StarsSchema, + db: Session = Depends(get_db), +) -> StarsResponseSchema: + star = get_star_by_id(db, star_id) + + if not star: + raise HTTPException( + status_code=404, + detail="Star with the given ID was not found." + ) + + if star_data.name: + star.name = star_data.name + + return StarsResponseSchema.model_validate(star) + + +@router.delete("/stars/{star_id}/") +def star_delete( + star_id: int, + db: Session = Depends(get_db), +) -> dict[str, str]: + star = get_star_by_id(db, star_id) + + if not star: + raise HTTPException( + status_code=404, + detail="Star with the given ID was not found." + ) + + db.delete(star) + db.commit() + return {"detail": "Star deleted successfully."} + + +@router.post("/genres/") +def create_genre( + genres_data: GenresSchema, + db: Session = Depends(get_db), +) -> GenreResponseSchema: + genre = get_genre_by_name(db, genres_data) + if genre: + raise HTTPException( + status_code=400, + detail="Genre already exists.", + ) + + genre = Genre(name=genres_data.name) + db.add(genre) + db.commit() + db.refresh(genre) + + return GenreResponseSchema.model_validate(genre) + + +@router.get("/genres/", response_model=list[GenreResponseSchema]) +def genre_list( + db: Session = Depends(get_db), +) -> list[Genre]: + return cast(List[Genre], get_all_genres(db)) + + +@router.get("/genres/{genre_id}/") +def genre_detail( + genre_id: int, + db: Session = Depends(get_db), +) -> GenreResponseSchema: + genre = get_genre_by_id(db, genre_id) + + if not genre: + raise HTTPException( + status_code=404, + detail="Genre with the given ID was not found." + ) + + return GenreResponseSchema.model_validate(genre) + + +@router.patch("/genres/{genre_id}/") +def genre_update( + genre_id: int, + genre_data: GenresSchema, + db: Session = Depends(get_db), +) -> GenreResponseSchema: + genre = get_genre_by_id(db, genre_id) + + if not genre: + raise HTTPException( + status_code=404, + detail="Genre with the given ID was not found." + ) + + if genre_data.name: + genre.name = genre_data.name + + return GenreResponseSchema.model_validate(genre) + + +@router.delete("/genres/{genre_id}/") +def genre_delete( + genre_id: int, + db: Session = Depends(get_db), +) -> dict[str, str]: + genre = get_genre_by_id(db, genre_id) + + if not genre: + raise HTTPException( + status_code=404, + detail="Genre with the given ID was not found." + ) + + db.delete(genre) + db.commit() + return {"detail": "Genre deleted successfully."} + + +@router.post("/{movie_id}/like/", response_model=MovieLikeResponseSchema) +def like_or_dislike( + movie_id: int, + token: str = Depends(get_token), + jwt_manager: JWTAuthManagerInterface = Depends(get_jwt_auth_manager), + db: Session = Depends(get_db), +) -> MovieLikeResponseSchema: + movie = get_movie_by_id(db, movie_id) + if not movie: + raise HTTPException( + status_code=404, + detail="Movie with the given ID was not found." + ) + token_data = jwt_manager.decode_access_token(token) + user_id = token_data["user_id"] + user = get_user_by_id(db, user_id) + + if not user: + raise HTTPException( + status_code=404, + detail="User with the given ID was not found." + ) + + movie_like = get_liked_movie(db, movie, user) + + if movie_like: + movie_like.is_liked = not movie_like.is_liked + else: + movie_like = MovieLike( + user_id=user_id, + movie_id=movie_id, + is_liked=True, + ) + db.add(movie_like) + + db.commit() + db.refresh(movie_like) + + return MovieLikeResponseSchema( + is_liked=movie_like.is_liked, + created_at=movie_like.created_at, + user=movie_like.user, + movie=movie_like.movie, + ) + + +@router.post("/{movie_id}/favorite/", response_model=MovieFavoriteResponseSchema) +def favorite_or_unfavorite( + movie_id: int, + token: str = Depends(get_token), + jwt_manager: JWTAuthManagerInterface = Depends(get_jwt_auth_manager), + db: Session = Depends(get_db), +) -> MovieFavoriteResponseSchema: + movie = get_movie_by_id(db, movie_id) + + if not movie: + raise HTTPException( + status_code=404, + detail="Movie with the given ID was not found." + ) + + token_data = jwt_manager.decode_access_token(token) + user_id = token_data["user_id"] + user = get_user_by_id(db, user_id) + + if not user: + raise HTTPException( + status_code=404, + detail="User with the given ID was not found." + ) + + movie_favorite = get_favourite_movie(db, movie, user) + + if movie_favorite: + movie_favorite.is_favorited = not movie_favorite.is_favorited + + else: + movie_favorite = FavoriteMovie( + user_id=user_id, + movie_id=movie_id, + is_favorited=True, + ) + db.add(movie_favorite) + + db.commit() + db.refresh(movie_favorite) + + return MovieFavoriteResponseSchema( + is_favorited=movie_favorite.is_favorited, + created_at=movie_favorite.created_at, + user=movie_favorite.user, + movie=movie_favorite.movie, + ) diff --git a/src/schemas/movies.py b/src/schemas/movies.py new file mode 100644 index 0000000..f617999 --- /dev/null +++ b/src/schemas/movies.py @@ -0,0 +1,153 @@ +import enum +from datetime import datetime +from typing import List, Optional +from uuid import UUID, uuid4 + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from schemas.accounts import UserRegistrationResponseSchema + + +class CertificationSchema(BaseModel): + name: str + + model_config = ConfigDict(from_attributes=True) + +class CertificationResponseSchema(CertificationSchema): + id: int + + +class GenresSchema(BaseModel): + name: str + + model_config = ConfigDict(from_attributes=True) + +class GenreResponseSchema(GenresSchema): + id: int + + +class StarsSchema(BaseModel): + name: str + + model_config = ConfigDict(from_attributes=True) + +class StarsResponseSchema(StarsSchema): + id: int + +class DirectorsSchema(BaseModel): + name: str + + model_config = ConfigDict(from_attributes=True) + + +class MovieListItemSchema(BaseModel): + id: int + name: str + year: int + time: int + description: str + + model_config = ConfigDict(from_attributes=True) + + +class MovieListResponseSchema(BaseModel): + movies: List[MovieListItemSchema] + prev_page: Optional[str] + next_page: Optional[str] + total_pages: int + total_items: int + + model_config = ConfigDict(from_attributes=True) + + +class MovieBaseSchema(BaseModel): + name: str = Field(..., max_length=255) + year: int + time: int = Field(..., ge=0) + imdb: float = Field(..., ge=0, le=10) + votes: int = Field(..., ge=0) + description: str + price: float = Field(..., ge=0) + + + model_config = ConfigDict(from_attributes=True) + + @field_validator("year") + @classmethod + def validate_year(cls, value: int) -> int: + first_movie_year = 1888 + current_year = datetime.now().year + if value < first_movie_year or value > current_year: + raise ValueError( + f"Year must be between {first_movie_year} and {current_year}." + ) + return value + + +class MovieCreateSchema(MovieBaseSchema): + meta_score: Optional[float] = Field(None, ge=0, le=100) + gross: Optional[float] = Field(None, ge=0) + certification: str + genres: list[str] + stars: list[str] + directors: list[str] + + +class MovieDetailSchema(MovieBaseSchema): + id: int + uuid: str + meta_score: Optional[float] = Field(None, ge=0, le=100) + gross: Optional[float] = Field(None, ge=0) + certification: CertificationResponseSchema + genres: List[GenresSchema] + stars: List[StarsSchema] + directors: List[DirectorsSchema] + + model_config = ConfigDict(from_attributes=True) + + +class MovieUpdateSchema(BaseModel): + name: Optional[str] = Field(None, max_length=255) + year: Optional[int] + time: Optional[int] = Field(None, ge=0) + imdb: Optional[float] = Field(None, ge=0, le=10) + votes: Optional[int] = Field(None, ge=0) + description: Optional[str] + price: Optional[float] = Field(None, ge=0) + meta_score: Optional[float] = Field(None, ge=0, le=100) + gross: Optional[float] = Field(None, ge=0) + + +class MovieLikeResponseSchema(BaseModel): + # user_id: int = Field(None, ge=0) + # movie_id: int = Field(None, ge=0) + is_liked: bool + created_at: datetime + user: UserRegistrationResponseSchema + movie: MovieListItemSchema + + class Config: + from_attributes = True + + +class MovieFavoriteResponseSchema(BaseModel): + # user_id: int = Field(None, ge=0) + # movie_id: int = Field(None, ge=0) + is_favorited: bool + created_at: datetime + user: UserRegistrationResponseSchema + movie: MovieListItemSchema + + class Config: + from_attributes = True + + +class MovieSortEnum(str, enum.Enum): + PRICE_ASC = "price_asc" + PRICE_DESC = "price_desc" + RELEASE_YEAR_ASC = "release_year_asc" + RELEASE_YEAR_DESC = "release_year_desc" + VOTES_ASC = "votes_asc" + VOTES_DESC = "votes_desc" + IMDb_ASC = "imdb_asc" + IMDb_DESC = "imdb_desc" From d9f2688a14672b14da3f45ca4594b82056b69c47 Mon Sep 17 00:00:00 2001 From: Bondzik-S Date: Mon, 3 Feb 2025 23:25:38 +0200 Subject: [PATCH 3/6] feat: include movie router to main.py --- src/main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index 45809c8..f342271 100644 --- a/src/main.py +++ b/src/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi_pagination import add_pagination -from routes import accounts_router, payments_router, profiles_router +from routes import accounts_router, movies_router, payments_router, profiles_router from routes.shopping_cart import router as shopping_carts_router app = FastAPI( @@ -16,6 +16,9 @@ app.include_router( profiles_router, prefix=f"{api_version_prefix}/profiles", tags=["profiles"] ) +app.include_router( + movies_router, prefix=f"{api_version_prefix}/cinema", tags=["cinema"] +) app.include_router( payments_router, prefix=f"{api_version_prefix}/payments", tags=["payments"] ) From 04948babcba0027bacad72fe9fc7b3e08f6e4bc2 Mon Sep 17 00:00:00 2001 From: Bondzik-S Date: Mon, 3 Feb 2025 23:25:58 +0200 Subject: [PATCH 4/6] fix: fix typo --- src/database/migrations/env.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/database/migrations/env.py b/src/database/migrations/env.py index 08d8920..120a33e 100644 --- a/src/database/migrations/env.py +++ b/src/database/migrations/env.py @@ -7,7 +7,7 @@ movies, orders, payments, - shoping_cart, + shopping_cart, ) from database.models.base import Base from database.session_postgresql import postgresql_engine From a834151e50651820222b774980f752c789fe50db Mon Sep 17 00:00:00 2001 From: Bondzik-S Date: Mon, 3 Feb 2025 23:26:39 +0200 Subject: [PATCH 5/6] feat: add likes and favorites fields --- src/database/models/accounts.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/database/models/accounts.py b/src/database/models/accounts.py index 27122cd..cc820be 100644 --- a/src/database/models/accounts.py +++ b/src/database/models/accounts.py @@ -91,6 +91,12 @@ class User(Base): cart: Mapped["Cart"] = relationship("Cart", back_populates="user", uselist=False) orders: Mapped[list["Order"]] = relationship("Order", back_populates="user") payments: Mapped[list["Payment"]] = relationship("Payment", back_populates="user") + likes: Mapped[list["MovieLike"]] = relationship( + "MovieLike", back_populates="user", cascade="all, delete-orphan" + ) + favorites: Mapped[list["FavoriteMovie"]] = relationship( + "FavoriteMovie", back_populates="user", cascade="all, delete-orphan" + ) def __repr__(self) -> str: return f"" @@ -133,7 +139,7 @@ def verify_password(self, raw_password: str) -> Any | bool: return verify_password(raw_password, self._hashed_password) @validates("email") - def validate_email(self, value: str) -> Any: + def validate_email(self, key: str, value: str) -> Any: return validators.validate_email(value.lower()) From 23856453f267466da9f56797a7a9aa05226388ec Mon Sep 17 00:00:00 2001 From: Bondzik-S Date: Mon, 3 Feb 2025 23:27:01 +0200 Subject: [PATCH 6/6] feat: import movies_router --- src/routes/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/__init__.py b/src/routes/__init__.py index 64cc615..f078db2 100644 --- a/src/routes/__init__.py +++ b/src/routes/__init__.py @@ -1,3 +1,4 @@ from routes.accounts import router as accounts_router from routes.payments import router as payments_router from routes.profiles import router as profiles_router +from routes.movies import router as movies_router