diff --git a/README.md b/README.md index e8acfba..74156dc 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,112 @@ -# FastAPI Application +# Movie Database API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A simple backend for a movie website inspired by IMDb, built with FastAPI and SQLite. + +## Features + +- **Movies**: Create, read, update, and delete movie information +- **Actors**: Manage actor profiles and their roles in movies +- **Directors**: Manage director profiles and their filmography +- **Genres**: Categorize movies by genre +- **Search**: Search for movies by title or overview +- **Filtering**: Filter movies by various criteria (title, director, actor, genre, rating, year) + +## Technical Stack + +- **Framework**: FastAPI +- **Database**: SQLite with SQLAlchemy ORM +- **Migrations**: Alembic +- **Validation**: Pydantic +- **Linting**: Ruff + +## Getting Started + +### Prerequisites + +- Python 3.8 or higher + +### Installation + +1. Clone the repository + +```bash +git clone +cd moviedbbackendservice-kduc3s +``` + +2. Install dependencies + +```bash +pip install -r requirements.txt +``` + +3. Run database migrations + +```bash +alembic upgrade head +``` + +4. Start the server + +```bash +uvicorn main:app --reload +``` + +The API will be available at http://localhost:8000 + +## API Documentation + +Once the server is running, you can access the interactive API documentation: + +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## Database Schema + +The application uses the following database schema: + +- **Movie**: Stores movie information (title, release date, overview, poster, runtime, rating) +- **Actor**: Stores actor information (name, birth date, bio, photo) +- **Director**: Stores director information (name, birth date, bio, photo) +- **Genre**: Stores genre categories (name) +- **MovieActor**: Junction table for the many-to-many relationship between movies and actors +- **MovieGenre**: Junction table for the many-to-many relationship between movies and genres + +## API Endpoints + +### Health Check + +- `GET /health`: Check the health status of the API + +### Movies + +- `GET /api/movies`: List all movies with pagination and filtering +- `POST /api/movies`: Create a new movie +- `GET /api/movies/{movie_id}`: Get details of a specific movie +- `PUT /api/movies/{movie_id}`: Update a movie +- `DELETE /api/movies/{movie_id}`: Delete a movie +- `GET /api/movies/search/{query}`: Search for movies by title or overview + +### Actors + +- `GET /api/actors`: List all actors with pagination and filtering +- `POST /api/actors`: Create a new actor +- `GET /api/actors/{actor_id}`: Get details of a specific actor +- `PUT /api/actors/{actor_id}`: Update an actor +- `DELETE /api/actors/{actor_id}`: Delete an actor + +### Directors + +- `GET /api/directors`: List all directors with pagination and filtering +- `POST /api/directors`: Create a new director +- `GET /api/directors/{director_id}`: Get details of a specific director +- `PUT /api/directors/{director_id}`: Update a director +- `DELETE /api/directors/{director_id}`: Delete a director + +### Genres + +- `GET /api/genres`: List all genres with pagination and filtering +- `POST /api/genres`: Create a new genre +- `GET /api/genres/{genre_id}`: Get details of a specific genre +- `PUT /api/genres/{genre_id}`: Update a genre +- `DELETE /api/genres/{genre_id}`: Delete a genre \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..77da623 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/app/api/crud/__init__.py b/app/api/crud/__init__.py new file mode 100644 index 0000000..2bcdea1 --- /dev/null +++ b/app/api/crud/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left empty to make the directory a Python package \ No newline at end of file diff --git a/app/api/crud/actor.py b/app/api/crud/actor.py new file mode 100644 index 0000000..a9d7896 --- /dev/null +++ b/app/api/crud/actor.py @@ -0,0 +1,86 @@ +from typing import List, Optional, Dict, Any +from sqlalchemy.orm import Session + +from app.models.actor import Actor +from app.models.movie_actor import MovieActor +from app.api.schemas.actor import ActorCreate, ActorUpdate + + +def get(db: Session, actor_id: int) -> Optional[Actor]: + """ + Get an actor by ID. + """ + return db.query(Actor).filter(Actor.id == actor_id).first() + + +def get_multi( + db: Session, + skip: int = 0, + limit: int = 100, + filters: Optional[Dict[str, Any]] = None +) -> List[Actor]: + """ + Get multiple actors with pagination and optional filters. + """ + query = db.query(Actor) + + # Apply filters if provided + if filters: + if name := filters.get("name"): + query = query.filter(Actor.name.ilike(f"%{name}%")) + + if movie_id := filters.get("movie_id"): + query = query.join(MovieActor).filter(MovieActor.movie_id == movie_id) + + # Sort by name + query = query.order_by(Actor.name) + + # Get total count before pagination + total = query.count() + + return query.offset(skip).limit(limit).all(), total + + +def create(db: Session, actor_in: ActorCreate) -> Actor: + """ + Create a new actor. + """ + db_actor = Actor(**actor_in.dict()) + db.add(db_actor) + db.commit() + db.refresh(db_actor) + return db_actor + + +def update( + db: Session, + db_actor: Actor, + actor_in: ActorUpdate +) -> Actor: + """ + Update an actor. + """ + update_data = actor_in.dict(exclude_unset=True) + + for field, value in update_data.items(): + setattr(db_actor, field, value) + + db.commit() + db.refresh(db_actor) + return db_actor + + +def delete(db: Session, actor_id: int) -> bool: + """ + Delete an actor by ID. + """ + db_actor = db.query(Actor).filter(Actor.id == actor_id).first() + if db_actor: + # Delete associated movie-actor relationships + db.query(MovieActor).filter(MovieActor.actor_id == actor_id).delete() + + # Delete the actor + db.delete(db_actor) + db.commit() + return True + return False \ No newline at end of file diff --git a/app/api/crud/director.py b/app/api/crud/director.py new file mode 100644 index 0000000..07dc964 --- /dev/null +++ b/app/api/crud/director.py @@ -0,0 +1,85 @@ +from typing import List, Optional, Dict, Any +from sqlalchemy.orm import Session + +from app.models.director import Director +from app.models.movie import Movie +from app.api.schemas.director import DirectorCreate, DirectorUpdate + + +def get(db: Session, director_id: int) -> Optional[Director]: + """ + Get a director by ID. + """ + return db.query(Director).filter(Director.id == director_id).first() + + +def get_multi( + db: Session, + skip: int = 0, + limit: int = 100, + filters: Optional[Dict[str, Any]] = None +) -> List[Director]: + """ + Get multiple directors with pagination and optional filters. + """ + query = db.query(Director) + + # Apply filters if provided + if filters: + if name := filters.get("name"): + query = query.filter(Director.name.ilike(f"%{name}%")) + + # Sort by name + query = query.order_by(Director.name) + + # Get total count before pagination + total = query.count() + + return query.offset(skip).limit(limit).all(), total + + +def create(db: Session, director_in: DirectorCreate) -> Director: + """ + Create a new director. + """ + db_director = Director(**director_in.dict()) + db.add(db_director) + db.commit() + db.refresh(db_director) + return db_director + + +def update( + db: Session, + db_director: Director, + director_in: DirectorUpdate +) -> Director: + """ + Update a director. + """ + update_data = director_in.dict(exclude_unset=True) + + for field, value in update_data.items(): + setattr(db_director, field, value) + + db.commit() + db.refresh(db_director) + return db_director + + +def delete(db: Session, director_id: int) -> bool: + """ + Delete a director by ID. + """ + db_director = db.query(Director).filter(Director.id == director_id).first() + if db_director: + # Unlink movies from this director + db.query(Movie).filter(Movie.director_id == director_id).update( + {Movie.director_id: None}, synchronize_session=False + ) + + # Delete the director + db.delete(db_director) + db.commit() + return True + return False \ No newline at end of file diff --git a/app/api/crud/genre.py b/app/api/crud/genre.py new file mode 100644 index 0000000..78441ba --- /dev/null +++ b/app/api/crud/genre.py @@ -0,0 +1,93 @@ +from typing import List, Optional, Dict, Any +from sqlalchemy.orm import Session + +from app.models.genre import Genre +from app.models.movie_genre import MovieGenre +from app.api.schemas.genre import GenreCreate, GenreUpdate + + +def get(db: Session, genre_id: int) -> Optional[Genre]: + """ + Get a genre by ID. + """ + return db.query(Genre).filter(Genre.id == genre_id).first() + + +def get_by_name(db: Session, name: str) -> Optional[Genre]: + """ + Get a genre by name. + """ + return db.query(Genre).filter(Genre.name == name).first() + + +def get_multi( + db: Session, + skip: int = 0, + limit: int = 100, + filters: Optional[Dict[str, Any]] = None +) -> List[Genre]: + """ + Get multiple genres with pagination and optional filters. + """ + query = db.query(Genre) + + # Apply filters if provided + if filters: + if name := filters.get("name"): + query = query.filter(Genre.name.ilike(f"%{name}%")) + + if movie_id := filters.get("movie_id"): + query = query.join(MovieGenre).filter(MovieGenre.movie_id == movie_id) + + # Sort by name + query = query.order_by(Genre.name) + + # Get total count before pagination + total = query.count() + + return query.offset(skip).limit(limit).all(), total + + +def create(db: Session, genre_in: GenreCreate) -> Genre: + """ + Create a new genre. + """ + db_genre = Genre(**genre_in.dict()) + db.add(db_genre) + db.commit() + db.refresh(db_genre) + return db_genre + + +def update( + db: Session, + db_genre: Genre, + genre_in: GenreUpdate +) -> Genre: + """ + Update a genre. + """ + update_data = genre_in.dict(exclude_unset=True) + + for field, value in update_data.items(): + setattr(db_genre, field, value) + + db.commit() + db.refresh(db_genre) + return db_genre + + +def delete(db: Session, genre_id: int) -> bool: + """ + Delete a genre by ID. + """ + db_genre = db.query(Genre).filter(Genre.id == genre_id).first() + if db_genre: + # Delete associated movie-genre relationships + db.query(MovieGenre).filter(MovieGenre.genre_id == genre_id).delete() + + # Delete the genre + db.delete(db_genre) + db.commit() + return True + return False \ No newline at end of file diff --git a/app/api/crud/movie.py b/app/api/crud/movie.py new file mode 100644 index 0000000..9bf95cd --- /dev/null +++ b/app/api/crud/movie.py @@ -0,0 +1,156 @@ +from typing import List, Optional, Dict, Any +from sqlalchemy.orm import Session +from sqlalchemy import or_, desc + +from app.models.movie import Movie +from app.models.movie_actor import MovieActor +from app.models.movie_genre import MovieGenre +from app.api.schemas.movie import MovieCreate, MovieUpdate + + +def get(db: Session, movie_id: int) -> Optional[Movie]: + """ + Get a movie by ID. + """ + return db.query(Movie).filter(Movie.id == movie_id).first() + + +def get_multi( + db: Session, + skip: int = 0, + limit: int = 100, + filters: Optional[Dict[str, Any]] = None +) -> List[Movie]: + """ + Get multiple movies with pagination and optional filters. + """ + query = db.query(Movie) + + # Apply filters if provided + if filters: + if title := filters.get("title"): + query = query.filter(Movie.title.ilike(f"%{title}%")) + + if director_id := filters.get("director_id"): + query = query.filter(Movie.director_id == director_id) + + if genre_id := filters.get("genre_id"): + query = query.join(MovieGenre).filter(MovieGenre.genre_id == genre_id) + + if actor_id := filters.get("actor_id"): + query = query.join(MovieActor).filter(MovieActor.actor_id == actor_id) + + if min_rating := filters.get("min_rating"): + query = query.filter(Movie.rating >= min_rating) + + if year := filters.get("year"): + from sqlalchemy import extract + query = query.filter(extract('year', Movie.release_date) == year) + + # Sort by rating (highest first) then by title + query = query.order_by(desc(Movie.rating), Movie.title) + + # Get total count before pagination + total = query.count() + + return query.offset(skip).limit(limit).all(), total + + +def create(db: Session, movie_in: MovieCreate) -> Movie: + """ + Create a new movie. + """ + movie_data = movie_in.dict(exclude={"genre_ids", "actor_ids"}) + db_movie = Movie(**movie_data) + db.add(db_movie) + db.commit() + db.refresh(db_movie) + + # Add genres if any + if movie_in.genre_ids: + for genre_id in movie_in.genre_ids: + db_movie_genre = MovieGenre(movie_id=db_movie.id, genre_id=genre_id) + db.add(db_movie_genre) + + # Add actors if any + if movie_in.actor_ids: + for actor_id in movie_in.actor_ids: + db_movie_actor = MovieActor(movie_id=db_movie.id, actor_id=actor_id) + db.add(db_movie_actor) + + if movie_in.genre_ids or movie_in.actor_ids: + db.commit() + db.refresh(db_movie) + + return db_movie + + +def update( + db: Session, + db_movie: Movie, + movie_in: MovieUpdate +) -> Movie: + """ + Update a movie. + """ + # Update movie attributes + update_data = movie_in.dict(exclude={"genre_ids", "actor_ids"}, exclude_unset=True) + + for field, value in update_data.items(): + setattr(db_movie, field, value) + + # Update genres if provided + if movie_in.genre_ids is not None: + # Remove existing genres + db.query(MovieGenre).filter(MovieGenre.movie_id == db_movie.id).delete() + + # Add new genres + for genre_id in movie_in.genre_ids: + db_movie_genre = MovieGenre(movie_id=db_movie.id, genre_id=genre_id) + db.add(db_movie_genre) + + # Update actors if provided + if movie_in.actor_ids is not None: + # Remove existing actors + db.query(MovieActor).filter(MovieActor.movie_id == db_movie.id).delete() + + # Add new actors + for actor_id in movie_in.actor_ids: + db_movie_actor = MovieActor(movie_id=db_movie.id, actor_id=actor_id) + db.add(db_movie_actor) + + db.commit() + db.refresh(db_movie) + return db_movie + + +def delete(db: Session, movie_id: int) -> bool: + """ + Delete a movie by ID. + """ + db_movie = db.query(Movie).filter(Movie.id == movie_id).first() + if db_movie: + # Delete associated movie-genre relationships + db.query(MovieGenre).filter(MovieGenre.movie_id == movie_id).delete() + + # Delete associated movie-actor relationships + db.query(MovieActor).filter(MovieActor.movie_id == movie_id).delete() + + # Delete the movie + db.delete(db_movie) + db.commit() + return True + return False + + +def search(db: Session, query: str, limit: int = 10) -> List[Movie]: + """ + Search for movies by title or overview. + """ + search_query = f"%{query}%" + return db.query(Movie).filter( + or_( + Movie.title.ilike(search_query), + Movie.overview.ilike(search_query) + ) + ).limit(limit).all() \ No newline at end of file diff --git a/app/api/endpoints/__init__.py b/app/api/endpoints/__init__.py new file mode 100644 index 0000000..2bcdea1 --- /dev/null +++ b/app/api/endpoints/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left empty to make the directory a Python package \ No newline at end of file diff --git a/app/api/endpoints/actors.py b/app/api/endpoints/actors.py new file mode 100644 index 0000000..5407019 --- /dev/null +++ b/app/api/endpoints/actors.py @@ -0,0 +1,94 @@ +from typing import Any, Optional +from fastapi import APIRouter, Depends, HTTPException, Query, Path +from sqlalchemy.orm import Session + +from app.api import crud +from app.api.schemas.actor import ( + Actor, ActorCreate, ActorUpdate, ActorDetails, ActorList +) +from app.db.session import get_db + +router = APIRouter(prefix="/actors") + + +@router.get("", response_model=ActorList) +def read_actors( + db: Session = Depends(get_db), + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), + name: Optional[str] = None, + movie_id: Optional[int] = None, +) -> Any: + """ + Retrieve all actors with optional filtering. + """ + # Build filter dict from query parameters + filters = {} + if name: + filters["name"] = name + if movie_id: + filters["movie_id"] = movie_id + + actors, total = crud.actor.get_multi(db, skip=skip, limit=limit, filters=filters) + return {"data": actors, "total": total} + + +@router.post("", response_model=Actor, status_code=201) +def create_actor( + *, + db: Session = Depends(get_db), + actor_in: ActorCreate, +) -> Any: + """ + Create a new actor. + """ + actor = crud.actor.create(db, actor_in=actor_in) + return actor + + +@router.get("/{actor_id}", response_model=ActorDetails) +def read_actor( + *, + db: Session = Depends(get_db), + actor_id: int = Path(..., ge=1), +) -> Any: + """ + Get a specific actor by ID. + """ + actor = crud.actor.get(db, actor_id=actor_id) + if not actor: + raise HTTPException(status_code=404, detail="Actor not found") + return actor + + +@router.put("/{actor_id}", response_model=Actor) +def update_actor( + *, + db: Session = Depends(get_db), + actor_id: int = Path(..., ge=1), + actor_in: ActorUpdate, +) -> Any: + """ + Update an actor. + """ + actor = crud.actor.get(db, actor_id=actor_id) + if not actor: + raise HTTPException(status_code=404, detail="Actor not found") + + actor = crud.actor.update(db, db_actor=actor, actor_in=actor_in) + return actor + + +@router.delete("/{actor_id}", status_code=204, response_model=None) +def delete_actor( + *, + db: Session = Depends(get_db), + actor_id: int = Path(..., ge=1), +) -> Any: + """ + Delete an actor. + """ + success = crud.actor.delete(db, actor_id=actor_id) + if not success: + raise HTTPException(status_code=404, detail="Actor not found") + return None \ No newline at end of file diff --git a/app/api/endpoints/directors.py b/app/api/endpoints/directors.py new file mode 100644 index 0000000..f16319f --- /dev/null +++ b/app/api/endpoints/directors.py @@ -0,0 +1,91 @@ +from typing import Any, Optional +from fastapi import APIRouter, Depends, HTTPException, Query, Path +from sqlalchemy.orm import Session + +from app.api import crud +from app.api.schemas.director import ( + Director, DirectorCreate, DirectorUpdate, DirectorDetails, DirectorList +) +from app.db.session import get_db + +router = APIRouter(prefix="/directors") + + +@router.get("", response_model=DirectorList) +def read_directors( + db: Session = Depends(get_db), + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), + name: Optional[str] = None, +) -> Any: + """ + Retrieve all directors with optional filtering. + """ + # Build filter dict from query parameters + filters = {} + if name: + filters["name"] = name + + directors, total = crud.director.get_multi(db, skip=skip, limit=limit, filters=filters) + return {"data": directors, "total": total} + + +@router.post("", response_model=Director, status_code=201) +def create_director( + *, + db: Session = Depends(get_db), + director_in: DirectorCreate, +) -> Any: + """ + Create a new director. + """ + director = crud.director.create(db, director_in=director_in) + return director + + +@router.get("/{director_id}", response_model=DirectorDetails) +def read_director( + *, + db: Session = Depends(get_db), + director_id: int = Path(..., ge=1), +) -> Any: + """ + Get a specific director by ID. + """ + director = crud.director.get(db, director_id=director_id) + if not director: + raise HTTPException(status_code=404, detail="Director not found") + return director + + +@router.put("/{director_id}", response_model=Director) +def update_director( + *, + db: Session = Depends(get_db), + director_id: int = Path(..., ge=1), + director_in: DirectorUpdate, +) -> Any: + """ + Update a director. + """ + director = crud.director.get(db, director_id=director_id) + if not director: + raise HTTPException(status_code=404, detail="Director not found") + + director = crud.director.update(db, db_director=director, director_in=director_in) + return director + + +@router.delete("/{director_id}", status_code=204, response_model=None) +def delete_director( + *, + db: Session = Depends(get_db), + director_id: int = Path(..., ge=1), +) -> Any: + """ + Delete a director. + """ + success = crud.director.delete(db, director_id=director_id) + if not success: + raise HTTPException(status_code=404, detail="Director not found") + return None \ No newline at end of file diff --git a/app/api/endpoints/genres.py b/app/api/endpoints/genres.py new file mode 100644 index 0000000..f8e5e4c --- /dev/null +++ b/app/api/endpoints/genres.py @@ -0,0 +1,111 @@ +from typing import Any, Optional +from fastapi import APIRouter, Depends, HTTPException, Query, Path +from sqlalchemy.orm import Session + +from app.api import crud +from app.api.schemas.genre import ( + Genre, GenreCreate, GenreUpdate, GenreDetails, GenreList +) +from app.db.session import get_db + +router = APIRouter(prefix="/genres") + + +@router.get("", response_model=GenreList) +def read_genres( + db: Session = Depends(get_db), + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), + name: Optional[str] = None, + movie_id: Optional[int] = None, +) -> Any: + """ + Retrieve all genres with optional filtering. + """ + # Build filter dict from query parameters + filters = {} + if name: + filters["name"] = name + if movie_id: + filters["movie_id"] = movie_id + + genres, total = crud.genre.get_multi(db, skip=skip, limit=limit, filters=filters) + return {"data": genres, "total": total} + + +@router.post("", response_model=Genre, status_code=201) +def create_genre( + *, + db: Session = Depends(get_db), + genre_in: GenreCreate, +) -> Any: + """ + Create a new genre. + """ + # Check if a genre with this name already exists + existing_genre = crud.genre.get_by_name(db, name=genre_in.name) + if existing_genre: + raise HTTPException( + status_code=400, + detail=f"Genre with name {genre_in.name} already exists" + ) + + genre = crud.genre.create(db, genre_in=genre_in) + return genre + + +@router.get("/{genre_id}", response_model=GenreDetails) +def read_genre( + *, + db: Session = Depends(get_db), + genre_id: int = Path(..., ge=1), +) -> Any: + """ + Get a specific genre by ID. + """ + genre = crud.genre.get(db, genre_id=genre_id) + if not genre: + raise HTTPException(status_code=404, detail="Genre not found") + return genre + + +@router.put("/{genre_id}", response_model=Genre) +def update_genre( + *, + db: Session = Depends(get_db), + genre_id: int = Path(..., ge=1), + genre_in: GenreUpdate, +) -> Any: + """ + Update a genre. + """ + genre = crud.genre.get(db, genre_id=genre_id) + if not genre: + raise HTTPException(status_code=404, detail="Genre not found") + + # Check if another genre with the new name already exists + if genre_in.name and genre_in.name != genre.name: + existing_genre = crud.genre.get_by_name(db, name=genre_in.name) + if existing_genre: + raise HTTPException( + status_code=400, + detail=f"Genre with name {genre_in.name} already exists" + ) + + genre = crud.genre.update(db, db_genre=genre, genre_in=genre_in) + return genre + + +@router.delete("/{genre_id}", status_code=204, response_model=None) +def delete_genre( + *, + db: Session = Depends(get_db), + genre_id: int = Path(..., ge=1), +) -> Any: + """ + Delete a genre. + """ + success = crud.genre.delete(db, genre_id=genre_id) + if not success: + raise HTTPException(status_code=404, detail="Genre not found") + return None \ No newline at end of file diff --git a/app/api/endpoints/health.py b/app/api/endpoints/health.py new file mode 100644 index 0000000..ab11530 --- /dev/null +++ b/app/api/endpoints/health.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api.schemas.health import HealthCheck +from app.db.session import get_db +from app.core.config import settings + +router = APIRouter(prefix="/health") + + +@router.get("", response_model=HealthCheck, tags=["health"]) +async def health_check(db: Session = Depends(get_db)): + """ + Checks the health of the application. + """ + health_data = { + "status": "ok", + "version": settings.VERSION, + "db_status": "ok", + "details": {} + } + + # Check database connection + try: + # Execute a simple query to check if the database is responding + db.execute("SELECT 1") + except Exception as e: + health_data["status"] = "error" + health_data["db_status"] = "error" + health_data["details"]["db_error"] = str(e) + + return health_data \ No newline at end of file diff --git a/app/api/endpoints/movies.py b/app/api/endpoints/movies.py new file mode 100644 index 0000000..c781cb6 --- /dev/null +++ b/app/api/endpoints/movies.py @@ -0,0 +1,120 @@ +from typing import Any, List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query, Path +from sqlalchemy.orm import Session + +from app.api import crud +from app.api.schemas.movie import ( + Movie, MovieCreate, MovieUpdate, MovieDetails, MovieList +) +from app.db.session import get_db + +router = APIRouter(prefix="/movies") + + +@router.get("", response_model=MovieList) +def read_movies( + db: Session = Depends(get_db), + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), + title: Optional[str] = None, + director_id: Optional[int] = None, + genre_id: Optional[int] = None, + actor_id: Optional[int] = None, + min_rating: Optional[float] = Query(None, ge=0, le=10), + year: Optional[int] = None, +) -> Any: + """ + Retrieve all movies with optional filtering. + """ + # Build filter dict from query parameters + filters = {} + if title: + filters["title"] = title + if director_id: + filters["director_id"] = director_id + if genre_id: + filters["genre_id"] = genre_id + if actor_id: + filters["actor_id"] = actor_id + if min_rating is not None: + filters["min_rating"] = min_rating + if year: + filters["year"] = year + + movies, total = crud.movie.get_multi(db, skip=skip, limit=limit, filters=filters) + return {"data": movies, "total": total} + + +@router.post("", response_model=Movie, status_code=201) +def create_movie( + *, + db: Session = Depends(get_db), + movie_in: MovieCreate, +) -> Any: + """ + Create a new movie. + """ + movie = crud.movie.create(db, movie_in=movie_in) + return movie + + +@router.get("/{movie_id}", response_model=MovieDetails) +def read_movie( + *, + db: Session = Depends(get_db), + movie_id: int = Path(..., ge=1), +) -> Any: + """ + Get a specific movie by ID. + """ + movie = crud.movie.get(db, movie_id=movie_id) + if not movie: + raise HTTPException(status_code=404, detail="Movie not found") + return movie + + +@router.put("/{movie_id}", response_model=Movie) +def update_movie( + *, + db: Session = Depends(get_db), + movie_id: int = Path(..., ge=1), + movie_in: MovieUpdate, +) -> Any: + """ + Update a movie. + """ + movie = crud.movie.get(db, movie_id=movie_id) + if not movie: + raise HTTPException(status_code=404, detail="Movie not found") + + movie = crud.movie.update(db, db_movie=movie, movie_in=movie_in) + return movie + + +@router.delete("/{movie_id}", status_code=204, response_model=None) +def delete_movie( + *, + db: Session = Depends(get_db), + movie_id: int = Path(..., ge=1), +) -> Any: + """ + Delete a movie. + """ + success = crud.movie.delete(db, movie_id=movie_id) + if not success: + raise HTTPException(status_code=404, detail="Movie not found") + return None + + +@router.get("/search/{query}", response_model=List[Movie]) +def search_movies( + *, + db: Session = Depends(get_db), + query: str = Path(..., min_length=2), + limit: int = Query(10, ge=1, le=100), +) -> Any: + """ + Search for movies by title or overview. + """ + movies = crud.movie.search(db, query=query, limit=limit) + return movies \ No newline at end of file diff --git a/app/api/schemas/__init__.py b/app/api/schemas/__init__.py new file mode 100644 index 0000000..2bcdea1 --- /dev/null +++ b/app/api/schemas/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left empty to make the directory a Python package \ No newline at end of file diff --git a/app/api/schemas/actor.py b/app/api/schemas/actor.py new file mode 100644 index 0000000..163f90d --- /dev/null +++ b/app/api/schemas/actor.py @@ -0,0 +1,72 @@ +from datetime import date +from typing import List, Optional +from pydantic import BaseModel + + +# Shared properties +class ActorBase(BaseModel): + name: str + birth_date: Optional[date] = None + bio: Optional[str] = None + photo_path: Optional[str] = None + + +# Properties to receive on actor creation +class ActorCreate(ActorBase): + pass + + +# Properties to receive on actor update +class ActorUpdate(ActorBase): + name: Optional[str] = None + + +# Properties shared by models stored in DB +class ActorInDBBase(ActorBase): + id: int + + class Config: + from_attributes = True + + +# Properties to return to client +class Actor(ActorInDBBase): + pass + + +# Properties properties stored in DB +class ActorInDB(ActorInDBBase): + pass + + +# Properties for actor with movie role +class ActorWithRole(Actor): + character_name: Optional[str] = None + + +# Properties for actor with details +class ActorDetails(Actor): + movies: List["MovieWithRole"] = [] + + + +# We need a simplified movie class to avoid circular import issues +class MovieWithRole(BaseModel): + id: int + title: str + release_date: Optional[date] = None + poster_path: Optional[str] = None + character_name: Optional[str] = None + + class Config: + from_attributes = True + + +# Update forward references +ActorDetails.model_rebuild() + + +# Properties for list +class ActorList(BaseModel): + data: List[Actor] + total: int \ No newline at end of file diff --git a/app/api/schemas/director.py b/app/api/schemas/director.py new file mode 100644 index 0000000..9ea0704 --- /dev/null +++ b/app/api/schemas/director.py @@ -0,0 +1,67 @@ +from datetime import date +from typing import List, Optional +from pydantic import BaseModel + + +# Shared properties +class DirectorBase(BaseModel): + name: str + birth_date: Optional[date] = None + bio: Optional[str] = None + photo_path: Optional[str] = None + + +# Properties to receive on director creation +class DirectorCreate(DirectorBase): + pass + + +# Properties to receive on director update +class DirectorUpdate(DirectorBase): + name: Optional[str] = None + + +# Properties shared by models stored in DB +class DirectorInDBBase(DirectorBase): + id: int + + class Config: + from_attributes = True + + +# Properties to return to client +class Director(DirectorInDBBase): + pass + + +# Properties properties stored in DB +class DirectorInDB(DirectorInDBBase): + pass + + +# Properties for director with details +class DirectorDetails(Director): + movies: List["MovieSummary"] = [] + + + +# We need a simplified movie class to avoid circular import issues +class MovieSummary(BaseModel): + id: int + title: str + release_date: Optional[date] = None + poster_path: Optional[str] = None + rating: Optional[float] = None + + class Config: + from_attributes = True + + +# Update forward references +DirectorDetails.model_rebuild() + + +# Properties for list +class DirectorList(BaseModel): + data: List[Director] + total: int \ No newline at end of file diff --git a/app/api/schemas/genre.py b/app/api/schemas/genre.py new file mode 100644 index 0000000..8579303 --- /dev/null +++ b/app/api/schemas/genre.py @@ -0,0 +1,58 @@ +from typing import List, Optional +from datetime import date +from pydantic import BaseModel + + +# Shared properties +class GenreBase(BaseModel): + name: str + + +# Properties to receive on genre creation +class GenreCreate(GenreBase): + pass + + +# Properties to receive on genre update +class GenreUpdate(GenreBase): + name: Optional[str] = None + + +# Properties shared by models stored in DB +class GenreInDBBase(GenreBase): + id: int + + class Config: + from_attributes = True + + +# Properties to return to client +class Genre(GenreInDBBase): + pass + + +# Properties properties stored in DB +class GenreInDB(GenreInDBBase): + pass + + +# We need a simplified movie class to avoid circular import issues +class MovieSummary(BaseModel): + id: int + title: str + release_date: Optional[date] = None + poster_path: Optional[str] = None + + class Config: + from_attributes = True + + +# Properties for genre with details +class GenreDetails(Genre): + movies: List[MovieSummary] = [] + + +# Properties for list +class GenreList(BaseModel): + data: List[Genre] + total: int \ No newline at end of file diff --git a/app/api/schemas/health.py b/app/api/schemas/health.py new file mode 100644 index 0000000..12db472 --- /dev/null +++ b/app/api/schemas/health.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel +from typing import Dict, Optional + + +class HealthCheck(BaseModel): + """ + Health check response schema. + """ + status: str + version: str + db_status: str + details: Optional[Dict[str, str]] = None \ No newline at end of file diff --git a/app/api/schemas/movie.py b/app/api/schemas/movie.py new file mode 100644 index 0000000..adc347c --- /dev/null +++ b/app/api/schemas/movie.py @@ -0,0 +1,77 @@ +from datetime import date +from typing import List, Optional, TYPE_CHECKING +from pydantic import BaseModel, Field + + +# Shared properties +class MovieBase(BaseModel): + title: str + release_date: Optional[date] = None + overview: Optional[str] = None + poster_path: Optional[str] = None + runtime: Optional[int] = None + rating: Optional[float] = Field(None, ge=0, le=10) + + +# Properties to receive on movie creation +class MovieCreate(MovieBase): + director_id: Optional[int] = None + genre_ids: List[int] = [] + actor_ids: List[int] = [] + + +# Properties to receive on movie update +class MovieUpdate(MovieBase): + title: Optional[str] = None + director_id: Optional[int] = None + genre_ids: Optional[List[int]] = None + actor_ids: Optional[List[int]] = None + + +# Properties shared by models stored in DB +class MovieInDBBase(MovieBase): + id: int + director_id: Optional[int] = None + + class Config: + from_attributes = True + + +# Properties to return to client +class Movie(MovieInDBBase): + pass + + +# Properties properties stored in DB +class MovieInDB(MovieInDBBase): + pass + + +# Forward references for circular imports +if TYPE_CHECKING: + from app.api.schemas.director import Director + from app.api.schemas.genre import Genre + from app.api.schemas.actor import Actor + + +# Properties for movie with details +class MovieDetails(Movie): + director: Optional["Director"] = None + genres: List["Genre"] = [] + actors: List["Actor"] = [] + + +# Import the forward references after the class definitions +# to avoid circular import issues +from app.api.schemas.director import Director # noqa: E402 +from app.api.schemas.genre import Genre # noqa: E402 +from app.api.schemas.actor import Actor # noqa: E402 + +# Update forward references +MovieDetails.model_rebuild() + + +# Properties for list +class MovieList(BaseModel): + data: List[Movie] + total: int \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..7aceeba --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,24 @@ +from pydantic_settings import BaseSettings +from typing import List + + +class Settings(BaseSettings): + # Project settings + PROJECT_NAME: str = "Movie Database API" + PROJECT_DESCRIPTION: str = "A simple backend for a movie website inspired by IMDb" + VERSION: str = "0.1.0" + DEBUG: bool = True + + # Server settings + HOST: str = "0.0.0.0" + PORT: int = 8000 + + # CORS settings + CORS_ORIGINS: List[str] = ["*"] + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +settings = Settings() diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..5d1c89c --- /dev/null +++ b/app/db/base.py @@ -0,0 +1 @@ +# Import all the models for Alembic diff --git a/app/db/base_class.py b/app/db/base_class.py new file mode 100644 index 0000000..a474ab1 --- /dev/null +++ b/app/db/base_class.py @@ -0,0 +1,13 @@ +from typing import Any +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + id: Any + __name__: str + + # Generate __tablename__ automatically + @declared_attr + def __tablename__(cls) -> str: + return cls.__name__.lower() \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..bd4ce21 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,24 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from pathlib import Path + +# Create database directory +DB_DIR = Path("/app") / "storage" / "db" +DB_DIR.mkdir(parents=True, exist_ok=True) + +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..2bcdea1 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ +# This file is intentionally left empty to make the directory a Python package \ No newline at end of file diff --git a/app/models/actor.py b/app/models/actor.py new file mode 100644 index 0000000..eeb706b --- /dev/null +++ b/app/models/actor.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, String, Date, Text +from sqlalchemy.orm import relationship + +from app.db.base_class import Base + + +class Actor(Base): + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False, index=True) + birth_date = Column(Date, nullable=True) + bio = Column(Text, nullable=True) + photo_path = Column(String(255), nullable=True) + + # Many-to-many relationship + movies = relationship("Movie", secondary="movieactor", back_populates="actors") \ No newline at end of file diff --git a/app/models/director.py b/app/models/director.py new file mode 100644 index 0000000..3373555 --- /dev/null +++ b/app/models/director.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, String, Date, Text +from sqlalchemy.orm import relationship + +from app.db.base_class import Base + + +class Director(Base): + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False, index=True) + birth_date = Column(Date, nullable=True) + bio = Column(Text, nullable=True) + photo_path = Column(String(255), nullable=True) + + # One-to-many relationship + movies = relationship("Movie", back_populates="director") \ No newline at end of file diff --git a/app/models/genre.py b/app/models/genre.py new file mode 100644 index 0000000..d54b608 --- /dev/null +++ b/app/models/genre.py @@ -0,0 +1,12 @@ +from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import relationship + +from app.db.base_class import Base + + +class Genre(Base): + id = Column(Integer, primary_key=True, index=True) + name = Column(String(50), nullable=False, unique=True, index=True) + + # Many-to-many relationship + movies = relationship("Movie", secondary="moviegenre", back_populates="genres") \ No newline at end of file diff --git a/app/models/movie.py b/app/models/movie.py new file mode 100644 index 0000000..b3531da --- /dev/null +++ b/app/models/movie.py @@ -0,0 +1,22 @@ +from sqlalchemy import Column, Integer, String, Date, Float, Text, ForeignKey +from sqlalchemy.orm import relationship + +from app.db.base_class import Base + + +class Movie(Base): + id = Column(Integer, primary_key=True, index=True) + title = Column(String(255), nullable=False, index=True) + release_date = Column(Date, nullable=True) + overview = Column(Text, nullable=True) + poster_path = Column(String(255), nullable=True) + runtime = Column(Integer, nullable=True) # in minutes + rating = Column(Float, nullable=True) # e.g., 8.5 + + # Relationships + director_id = Column(Integer, ForeignKey("director.id"), nullable=True) + director = relationship("Director", back_populates="movies") + + # Many-to-many relationships + actors = relationship("Actor", secondary="movieactor", back_populates="movies") + genres = relationship("Genre", secondary="moviegenre", back_populates="movies") \ No newline at end of file diff --git a/app/models/movie_actor.py b/app/models/movie_actor.py new file mode 100644 index 0000000..b8870c8 --- /dev/null +++ b/app/models/movie_actor.py @@ -0,0 +1,10 @@ +from sqlalchemy import Column, Integer, ForeignKey, String + +from app.db.base_class import Base + + +class MovieActor(Base): + id = Column(Integer, primary_key=True, index=True) + movie_id = Column(Integer, ForeignKey("movie.id"), nullable=False) + actor_id = Column(Integer, ForeignKey("actor.id"), nullable=False) + character_name = Column(String(255), nullable=True) \ No newline at end of file diff --git a/app/models/movie_genre.py b/app/models/movie_genre.py new file mode 100644 index 0000000..cb6d0d3 --- /dev/null +++ b/app/models/movie_genre.py @@ -0,0 +1,9 @@ +from sqlalchemy import Column, Integer, ForeignKey + +from app.db.base_class import Base + + +class MovieGenre(Base): + id = Column(Integer, primary_key=True, index=True) + movie_id = Column(Integer, ForeignKey("movie.id"), nullable=False) + genre_id = Column(Integer, ForeignKey("genre.id"), nullable=False) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..6a0fc6a --- /dev/null +++ b/main.py @@ -0,0 +1,38 @@ +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.endpoints import movies, actors, directors, genres, health +from app.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, + description=settings.PROJECT_DESCRIPTION, + version=settings.VERSION, + docs_url="/docs", + redoc_url="/redoc", +) + +# Set up CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(health.router, tags=["health"]) +app.include_router(movies.router, prefix="/api", tags=["movies"]) +app.include_router(actors.router, prefix="/api", tags=["actors"]) +app.include_router(directors.router, prefix="/api", tags=["directors"]) +app.include_router(genres.router, prefix="/api", tags=["genres"]) + +if __name__ == "__main__": + uvicorn.run( + "main:app", + host=settings.HOST, + port=settings.PORT, + reload=settings.DEBUG, + ) diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..6209aa5 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,82 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# Import the app database models to have them registered for Alembic +from app.db.base import Base # noqa: E402 + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + is_sqlite = connection.dialect.name == 'sqlite' + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=is_sqlite, # Key configuration for SQLite + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..3cf5352 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/migrations/versions/001_initial_schema.py b/migrations/versions/001_initial_schema.py new file mode 100644 index 0000000..e801b69 --- /dev/null +++ b/migrations/versions/001_initial_schema.py @@ -0,0 +1,109 @@ +"""Initial schema + +Revision ID: 001 +Revises: +Create Date: 2023-11-27 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '001' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('actor', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('birth_date', sa.Date(), nullable=True), + sa.Column('bio', sa.Text(), nullable=True), + sa.Column('photo_path', sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_actor_id'), 'actor', ['id'], unique=False) + op.create_index(op.f('ix_actor_name'), 'actor', ['name'], unique=False) + + op.create_table('director', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('birth_date', sa.Date(), nullable=True), + sa.Column('bio', sa.Text(), nullable=True), + sa.Column('photo_path', sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_director_id'), 'director', ['id'], unique=False) + op.create_index(op.f('ix_director_name'), 'director', ['name'], unique=False) + + op.create_table('genre', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=50), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_genre_id'), 'genre', ['id'], unique=False) + op.create_index(op.f('ix_genre_name'), 'genre', ['name'], unique=True) + + op.create_table('movie', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('release_date', sa.Date(), nullable=True), + sa.Column('overview', sa.Text(), nullable=True), + sa.Column('poster_path', sa.String(length=255), nullable=True), + sa.Column('runtime', sa.Integer(), nullable=True), + sa.Column('rating', sa.Float(), nullable=True), + sa.Column('director_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['director_id'], ['director.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_movie_id'), 'movie', ['id'], unique=False) + op.create_index(op.f('ix_movie_title'), 'movie', ['title'], unique=False) + + op.create_table('movieactor', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('movie_id', sa.Integer(), nullable=False), + sa.Column('actor_id', sa.Integer(), nullable=False), + sa.Column('character_name', sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint(['actor_id'], ['actor.id'], ), + sa.ForeignKeyConstraint(['movie_id'], ['movie.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_movieactor_id'), 'movieactor', ['id'], unique=False) + + op.create_table('moviegenre', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('movie_id', sa.Integer(), nullable=False), + sa.Column('genre_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['genre_id'], ['genre.id'], ), + sa.ForeignKeyConstraint(['movie_id'], ['movie.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_moviegenre_id'), 'moviegenre', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_moviegenre_id'), table_name='moviegenre') + op.drop_table('moviegenre') + op.drop_index(op.f('ix_movieactor_id'), table_name='movieactor') + op.drop_table('movieactor') + op.drop_index(op.f('ix_movie_title'), table_name='movie') + op.drop_index(op.f('ix_movie_id'), table_name='movie') + op.drop_table('movie') + op.drop_index(op.f('ix_genre_name'), table_name='genre') + op.drop_index(op.f('ix_genre_id'), table_name='genre') + op.drop_table('genre') + op.drop_index(op.f('ix_director_name'), table_name='director') + op.drop_index(op.f('ix_director_id'), table_name='director') + op.drop_table('director') + op.drop_index(op.f('ix_actor_name'), table_name='actor') + op.drop_index(op.f('ix_actor_id'), table_name='actor') + op.drop_table('actor') + # ### end Alembic commands ### \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e73653d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.104.0 +uvicorn>=0.23.2 +sqlalchemy>=2.0.23 +alembic>=1.12.1 +pydantic>=2.4.2 +python-dotenv>=1.0.0 +python-multipart>=0.0.6 +ruff>=0.1.6 +pyhumps>=3.8.0