diff --git a/docker-compose.yml b/docker-compose.yml index 1f1b42e..400af58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: env_file: - .env ports: - - "5432:5432" + - "5433:5432" volumes: - ./init.sql:/docker-entrypoint-initdb.d/init.sql - postgres_theater_data:/var/lib/postgresql/data/ @@ -45,7 +45,7 @@ services: - PYTHONPATH=/usr/src/fastapi - WATCHFILES_FORCE_POLLING=true ports: - - "8000:8000" + - "8080:8000" depends_on: db: condition: service_healthy diff --git a/src/database/migrations/env.py b/src/database/migrations/env.py index d3101ac..3ceca95 100644 --- a/src/database/migrations/env.py +++ b/src/database/migrations/env.py @@ -2,7 +2,7 @@ from alembic import context -from database import models # noqa: F401 +from database import models # noqa: F401 from database.models import Base from database.session_postgresql import postgresql_engine diff --git a/src/database/migrations/versions/d0c53c351d00_temp_migration.py b/src/database/migrations/versions/d0c53c351d00_temp_migration.py new file mode 100644 index 0000000..154ecf9 --- /dev/null +++ b/src/database/migrations/versions/d0c53c351d00_temp_migration.py @@ -0,0 +1,34 @@ +"""temp_migration + +Revision ID: d0c53c351d00 +Revises: ea3a65568bd9 +Create Date: 2025-02-17 16:28:57.022412 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd0c53c351d00' +down_revision: Union[str, None] = 'ea3a65568bd9' +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.alter_column('countries', 'name', + existing_type=sa.VARCHAR(length=255), + nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('countries', 'name', + existing_type=sa.VARCHAR(length=255), + nullable=True) + # ### end Alembic commands ### diff --git a/src/database/models.py b/src/database/models.py index 942a3af..5f76c75 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -1,9 +1,23 @@ import datetime from enum import Enum -from typing import Optional -from sqlalchemy import String, Float, Text, DECIMAL, UniqueConstraint, Date, ForeignKey, Table, Column -from sqlalchemy.orm import DeclarativeBase, mapped_column, Mapped, relationship +from sqlalchemy import ( + String, + Float, + Text, + DECIMAL, + UniqueConstraint, + Date, + ForeignKey, + Table, + Column +) +from sqlalchemy.orm import ( + DeclarativeBase, + mapped_column, + Mapped, + relationship +) from sqlalchemy import Enum as SQLAlchemyEnum @@ -24,10 +38,16 @@ class MovieStatusEnum(str, Enum): Base.metadata, Column( "movie_id", - ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True, nullable=False), + ForeignKey("movies.id", ondelete="CASCADE"), + primary_key=True, + nullable=False + ), Column( "genre_id", - ForeignKey("genres.id", ondelete="CASCADE"), primary_key=True, nullable=False), + ForeignKey("genres.id", ondelete="CASCADE"), + primary_key=True, + nullable=False + ), ) ActorsMoviesModel = Table( @@ -35,25 +55,38 @@ class MovieStatusEnum(str, Enum): Base.metadata, Column( "movie_id", - ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True, nullable=False), + ForeignKey("movies.id", ondelete="CASCADE"), + primary_key=True, + nullable=False + ), Column( "actor_id", - ForeignKey("actors.id", ondelete="CASCADE"), primary_key=True, nullable=False), + ForeignKey("actors.id", ondelete="CASCADE"), + primary_key=True, + nullable=False + ), ) MoviesLanguagesModel = Table( "movies_languages", Base.metadata, - Column("movie_id", ForeignKey("movies.id", ondelete="CASCADE"), primary_key=True), - Column("language_id", ForeignKey("languages.id", ondelete="CASCADE"), primary_key=True), + Column("movie_id", + ForeignKey("movies.id", ondelete="CASCADE"), + primary_key=True), + Column("language_id", + ForeignKey("languages.id", ondelete="CASCADE"), + primary_key=True), ) class GenreModel(Base): __tablename__ = "genres" - id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + id: Mapped[int] = mapped_column(primary_key=True, + autoincrement=True) + name: Mapped[str] = mapped_column(String(255), + unique=True, + nullable=False) movies: Mapped[list["MovieModel"]] = relationship( "MovieModel", @@ -69,7 +102,9 @@ class ActorModel(Base): __tablename__ = "actors" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), + unique=True, + nullable=False) movies: Mapped[list["MovieModel"]] = relationship( "MovieModel", @@ -86,9 +121,12 @@ class CountryModel(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) code: Mapped[str] = mapped_column(String(3), unique=True, nullable=False) - name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) - movies: Mapped[list["MovieModel"]] = relationship("MovieModel", back_populates="country") + movies: Mapped[list["MovieModel"]] = relationship( + "MovieModel", + back_populates="country" + ) def __repr__(self): return f"" @@ -98,7 +136,9 @@ class LanguageModel(Base): __tablename__ = "languages" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), + unique=True, + nullable=False) movies: Mapped[list["MovieModel"]] = relationship( "MovieModel", @@ -121,11 +161,14 @@ class MovieModel(Base): status: Mapped[MovieStatusEnum] = mapped_column( SQLAlchemyEnum(MovieStatusEnum), nullable=False ) - budget: Mapped[float] = mapped_column(DECIMAL(15, 2), nullable=False) + budget: Mapped[float] = mapped_column(DECIMAL(15, 2), + nullable=False) revenue: Mapped[float] = mapped_column(Float, nullable=False) - country_id: Mapped[int] = mapped_column(ForeignKey("countries.id"), nullable=False) - country: Mapped["CountryModel"] = relationship("CountryModel", back_populates="movies") + country_id: Mapped[int] = mapped_column(ForeignKey("countries.id"), + nullable=False) + country: Mapped["CountryModel"] = relationship("CountryModel", + back_populates="movies") genres: Mapped[list["GenreModel"]] = relationship( "GenreModel", @@ -146,7 +189,9 @@ class MovieModel(Base): ) __table_args__ = ( - UniqueConstraint("name", "date", name="unique_movie_constraint"), + UniqueConstraint("name", + "date", + name="unique_movie_constraint"), ) @classmethod @@ -154,4 +199,6 @@ def default_order_by(cls): return [cls.id.desc()] def __repr__(self): - return f"" + return (f"") diff --git a/src/database/populate.py b/src/database/populate.py index f5fb895..e624239 100644 --- a/src/database/populate.py +++ b/src/database/populate.py @@ -6,8 +6,15 @@ from config import get_settings from database import MovieModel, get_db_contextmanager -from database.models import CountryModel, GenreModel, ActorModel, MoviesGenresModel, ActorsMoviesModel, LanguageModel, \ +from database.models import ( + CountryModel, + GenreModel, + ActorModel, + MoviesGenresModel, + ActorsMoviesModel, + LanguageModel, MoviesLanguagesModel +) class CSVDatabaseSeeder: @@ -139,6 +146,7 @@ def seed(self): print(f"Unexpected error: {e}") raise + def main(): settings = get_settings() with get_db_contextmanager() as db_session: diff --git a/src/main.py b/src/main.py index 35ca2a7..9975689 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,5 @@ from fastapi import FastAPI - -from routes import movie_router +from routes.movies import router as movie_router app = FastAPI( title="Movies homework", @@ -9,4 +8,6 @@ api_version_prefix = "/api/v1" -app.include_router(movie_router, prefix=f"{api_version_prefix}/theater", tags=["theater"]) +app.include_router(movie_router, + prefix=f"{api_version_prefix}/theater", + tags=["theater"]) diff --git a/src/routes/movies.py b/src/routes/movies.py index e44678a..e053cf6 100644 --- a/src/routes/movies.py +++ b/src/routes/movies.py @@ -1,12 +1,259 @@ -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.exc import IntegrityError +from fastapi import APIRouter, HTTPException, Depends, status +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session, joinedload +from datetime import datetime +from database.models import ( + MovieModel, + GenreModel, + ActorModel, + LanguageModel, + CountryModel +) +from database.session_postgresql import get_postgresql_db -from database import get_db -from database.models import MovieModel, CountryModel, GenreModel, ActorModel, LanguageModel - +from schemas.movies import ( + MovieResponse, + MovieCreateResponse, + MovieCreateRequest, + PaginatedMoviesResponse, + MovieUpdateRequest +) router = APIRouter() -# Write your code here +def safe_commit(db: Session): + try: + db.commit() + except SQLAlchemyError as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Database error occurred while processing your request." + ) + + +@router.get("/movies/", response_model=PaginatedMoviesResponse) +async def get_movies(page: int = 1, + per_page: int = 10, + db: Session = Depends(get_postgresql_db)): + if page < 1 or per_page < 1 or per_page > 20: + raise HTTPException(status_code=400, + detail="Invalid pagination parameters") + + offset = (page - 1) * per_page + movies = db.query(MovieModel).options( + joinedload(MovieModel.genres), + joinedload(MovieModel.actors), + joinedload(MovieModel.languages) + ).offset(offset).limit(per_page).all() + + if not movies: + raise HTTPException(status_code=404, detail="No movies found") + + total_items = db.query(MovieModel).count() + total_pages = (total_items + per_page - 1) // per_page + + prev_page = (f"/movies/?page={page - 1}" + f"&per_page={per_page}") if page > 1 else None + next_page = (f"/movies/?page={page + 1}" + f"&per_page={per_page}") if page < total_pages else None + + movie_responses = [MovieResponse.from_orm(movie) for movie in movies] + + return PaginatedMoviesResponse( + movies=movie_responses, + prev_page=prev_page, + next_page=next_page, + total_pages=total_pages, + total_items=total_items + ) + + +@router.post("/movies/", response_model=MovieCreateResponse) +async def create_movie(movie: MovieCreateRequest, + db: Session = Depends(get_postgresql_db)): + existing_movie = db.query(MovieModel).filter( + MovieModel.name == movie.name, + MovieModel.date == movie.date + ).first() + + if existing_movie: + raise HTTPException(status_code=409, + detail=f"A movie with the name '{movie.name}' " + f"and release date '{movie.date}' already " + f"exists." + ) + + country = db.query(CountryModel).filter( + CountryModel.code == movie.country + ).first() + + if not country: + country = CountryModel(code=movie.country) + db.add(country) + safe_commit(db) + + genres = [] + for genre_name in movie.genres: + genre = db.query(GenreModel).filter( + GenreModel.name == genre_name + ).first() + + if not genre: + genre = GenreModel(name=genre_name) + db.add(genre) + genres.append(genre) + + actors = [] + for actor_name in movie.actors: + actor = db.query(ActorModel).filter( + ActorModel.name == actor_name + ).first() + + if not actor: + actor = ActorModel(name=actor_name) + db.add(actor) + actors.append(actor) + + languages = [] + for language_name in movie.languages: + language = db.query(LanguageModel).filter( + LanguageModel.name == language_name + ).first() + + if not language: + language = LanguageModel(name=language_name) + db.add(language) + languages.append(language) + + safe_commit(db) + + new_movie = MovieModel( + name=movie.name, + date=movie.date, + score=movie.score, + overview=movie.overview, + status=movie.status, + budget=movie.budget, + revenue=movie.revenue, + country=country, + genres=genres, + actors=actors, + languages=languages + ) + db.add(new_movie) + safe_commit(db) + db.refresh(new_movie) + + return MovieCreateResponse( + id=new_movie.id, + name=new_movie.name, + date=new_movie.date, + score=new_movie.score, + overview=new_movie.overview, + status=new_movie.status, + budget=new_movie.budget, + revenue=new_movie.revenue, + country={"id": country.id, + "code": country.code, + "name": country.name}, + genres=[{"id": genre.id, "name": genre.name} for genre in genres], + actors=[{"id": actor.id, "name": actor.name} for actor in actors], + languages=[{"id": language.id, + "name": language.name} for language in languages] + ) + + +@router.get("/movies/{movie_id}/", response_model=MovieCreateResponse) +async def get_movie_details(movie_id: int, + db: Session = Depends(get_postgresql_db)): + movie = db.query(MovieModel).filter(MovieModel.id == movie_id).first() + if not movie: + raise HTTPException(status_code=404, + detail="Movie with the given ID was not found.") + + return MovieCreateResponse( + id=movie.id, + name=movie.name, + date=movie.date, + score=movie.score, + overview=movie.overview, + status=movie.status, + budget=movie.budget, + revenue=movie.revenue, + country={"id": movie.country.id, + "code": movie.country.code, + "name": movie.country.name}, + genres=[{"id": genre.id, + "name": genre.name} for genre in movie.genres], + actors=[{"id": actor.id, + "name": actor.name} for actor in movie.actors], + languages=[{"id": language.id, + "name": language.name} for language in movie.languages] + ) + + +@router.delete("/movies/{movie_id}/", status_code=204) +async def delete_movie(movie_id: int, + db: Session = Depends(get_postgresql_db)): + movie = db.query(MovieModel).filter(MovieModel.id == movie_id).first() + + if not movie: + raise HTTPException(status_code=404, + detail="Movie with the given ID was not found.") + + db.delete(movie) + safe_commit(db) + + return {"detail": "Movie deleted successfully."} + + +@router.patch("/movies/{movie_id}/", status_code=status.HTTP_200_OK) +async def update_movie(movie_id: int, + movie_update: MovieUpdateRequest, + db: Session = Depends(get_postgresql_db)): + movie = db.query(MovieModel).filter(MovieModel.id == movie_id).first() + + if not movie: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, + detail="Movie with the given ID was not found.") + + if movie_update.score is not None and ( + movie_update.score < 0 or movie_update.score > 100): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid input data for score.") + + if movie_update.budget is not None and movie_update.budget < 0: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid input data for budget.") + + if movie_update.revenue is not None and movie_update.revenue < 0: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid input data for revenue.") + + if movie_update.name is not None: + movie.name = movie_update.name + if movie_update.date is not None: + try: + movie.date = datetime.strptime(movie_update.date, + "%Y-%m-%d").date() + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid date format. Use 'YYYY-MM-DD'." + ) + if movie_update.score is not None: + movie.score = movie_update.score + if movie_update.overview is not None: + movie.overview = movie_update.overview + if movie_update.status is not None: + movie.status = movie_update.status + if movie_update.budget is not None: + movie.budget = movie_update.budget + if movie_update.revenue is not None: + movie.revenue = movie_update.revenue + + safe_commit(db) + + return {"detail": "Movie updated successfully."} diff --git a/src/schemas/movies.py b/src/schemas/movies.py index fabb9be..6f7a5f2 100644 --- a/src/schemas/movies.py +++ b/src/schemas/movies.py @@ -1 +1,123 @@ -# Write your code here +from pydantic import BaseModel, root_validator, validator +from typing import List, Optional +from datetime import datetime, date + + +class MovieResponse(BaseModel): + id: int + name: str + date: date + score: float + overview: str + + class Config: + orm_mode = True + from_attributes = True + + +class PaginatedMoviesResponse(BaseModel): + movies: List[MovieResponse] + prev_page: Optional[str] = None + next_page: Optional[str] = None + total_pages: int + total_items: int + + +class CountryResponse(BaseModel): + id: int + code: str + name: str + + class Config: + orm_mode = True + from_attributes = True + + +class GenreResponse(BaseModel): + id: int + name: str + + class Config: + orm_mode = True + from_attributes = True + + +class ActorResponse(BaseModel): + id: int + name: str + + class Config: + orm_mode = True + from_attributes = True + + +class LanguageResponse(BaseModel): + id: int + name: str + + class Config: + orm_mode = True + from_attributes = True + + +class MovieCreateRequest(BaseModel): + name: str + date: date + score: float + overview: str + status: str + budget: float + revenue: float + country: str + genres: List[str] + actors: List[str] + languages: List[str] + + @validator('date', pre=True) + def parse_date(cls, value): + if isinstance(value, str): + try: + return datetime.strptime(value, "%Y-%m-%d").date() + except ValueError: + raise ValueError( + f"Invalid date format: {value}. " + f"Expected format: YYYY-MM-DD." + ) + return value + + +class MovieCreateResponse(BaseModel): + id: int + name: str + date: date + score: float + overview: str + status: str + budget: float + revenue: float + country: CountryResponse + genres: List[GenreResponse] + actors: List[ActorResponse] + languages: List[LanguageResponse] + + class Config: + orm_mode = True + from_attributes = True + + @root_validator(pre=True) + def convert_null_to_string(cls, values): + country = values.get("country") + if not country["name"]: + country["name"] = "null" + + return values + + +class MovieUpdateRequest(BaseModel): + name: Optional[str] = None + date: Optional[str] = None + score: Optional[float] = None + overview: Optional[str] = None + status: Optional[str] = None + budget: Optional[float] = None + revenue: Optional[float] = None