Create movie database backend with FastAPI and SQLite

This commit implements a simple movie database backend inspired by IMDb. It includes:
- API endpoints for movies, actors, directors and genres
- SQLAlchemy models with relationships
- Alembic migrations
- Pydantic schemas for request/response validation
- Search and filtering functionality
- Health check endpoint
- Complete documentation
This commit is contained in:
Automated Action 2025-05-19 20:28:07 +00:00
parent 6d329af103
commit 0186fc8e70
36 changed files with 1795 additions and 2 deletions

113
README.md
View File

@ -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 <repository-url>
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

116
alembic.ini Normal file
View File

@ -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

1
app/api/crud/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file is intentionally left empty to make the directory a Python package

86
app/api/crud/actor.py Normal file
View File

@ -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

85
app/api/crud/director.py Normal file
View File

@ -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

93
app/api/crud/genre.py Normal file
View File

@ -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

156
app/api/crud/movie.py Normal file
View File

@ -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()

View File

@ -0,0 +1 @@
# This file is intentionally left empty to make the directory a Python package

View File

@ -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

View File

@ -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

111
app/api/endpoints/genres.py Normal file
View File

@ -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

View File

@ -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

120
app/api/endpoints/movies.py Normal file
View File

@ -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

View File

@ -0,0 +1 @@
# This file is intentionally left empty to make the directory a Python package

72
app/api/schemas/actor.py Normal file
View File

@ -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

View File

@ -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

58
app/api/schemas/genre.py Normal file
View File

@ -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

12
app/api/schemas/health.py Normal file
View File

@ -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

77
app/api/schemas/movie.py Normal file
View File

@ -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

24
app/core/config.py Normal file
View File

@ -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()

1
app/db/base.py Normal file
View File

@ -0,0 +1 @@
# Import all the models for Alembic

13
app/db/base_class.py Normal file
View File

@ -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()

24
app/db/session.py Normal file
View File

@ -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()

1
app/models/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file is intentionally left empty to make the directory a Python package

15
app/models/actor.py Normal file
View File

@ -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")

15
app/models/director.py Normal file
View File

@ -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")

12
app/models/genre.py Normal file
View File

@ -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")

22
app/models/movie.py Normal file
View File

@ -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")

10
app/models/movie_actor.py Normal file
View File

@ -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)

View File

@ -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)

38
main.py Normal file
View File

@ -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,
)

1
migrations/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.

82
migrations/env.py Normal file
View File

@ -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()

26
migrations/script.py.mako Normal file
View File

@ -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"}

View File

@ -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 ###

9
requirements.txt Normal file
View File

@ -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