Implement online bookstore backend API

- Set up FastAPI project structure with SQLite and SQLAlchemy
- Create models for users, books, authors, categories, and orders
- Implement JWT authentication and authorization
- Add CRUD endpoints for all resources
- Set up Alembic for database migrations
- Add health check endpoint
- Add proper error handling and validation
- Create comprehensive documentation
This commit is contained in:
Automated Action 2025-05-20 12:04:27 +00:00
parent 2c9d939a91
commit f1c2b73ade
30 changed files with 2144 additions and 2 deletions

145
README.md
View File

@ -1,3 +1,144 @@
# FastAPI Application # Online Bookstore API
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. This is a FastAPI backend for an online bookstore application. It provides endpoints for managing books, authors, categories, users, and orders.
## Features
- User registration and authentication with JWT tokens
- Book management with support for authors and categories
- Order processing with inventory management
- Role-based access control (admins and regular users)
- Comprehensive API documentation with Swagger UI and ReDoc
- SQLite database with SQLAlchemy ORM
- Database migrations with Alembic
## Installation
### Prerequisites
- Python 3.8+
- pip
### Setup
1. Clone the repository:
```bash
git clone <repository-url>
cd onlinebookstorebackendapi
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Apply database migrations:
```bash
alembic upgrade head
```
## Usage
### Starting the Server
Run the following command to start the development server:
```bash
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
```
### API Documentation
Once the server is running, you can access the API documentation at:
- Swagger UI: [http://localhost:8000/docs](http://localhost:8000/docs)
- ReDoc: [http://localhost:8000/redoc](http://localhost:8000/redoc)
## API Endpoints
### Health Check
- `GET /health`: Check API and database health
### Authentication
- `POST /api/users/register`: Register a new user
- `POST /api/users/login`: Login to get access token
### Users
- `GET /api/users/me`: Get current user info
- `PUT /api/users/me`: Update current user info
- `GET /api/users/{user_id}`: Get user by ID (admin only)
- `PUT /api/users/{user_id}`: Update user (admin only)
- `DELETE /api/users/{user_id}`: Delete user (admin only)
### Books
- `GET /api/books`: List books with optional filters
- `POST /api/books`: Create a new book (admin only)
- `GET /api/books/{book_id}`: Get book details
- `PUT /api/books/{book_id}`: Update book (admin only)
- `DELETE /api/books/{book_id}`: Delete book (admin only)
### Authors
- `GET /api/books/authors`: List authors
- `POST /api/books/authors`: Create a new author (admin only)
- `GET /api/books/authors/{author_id}`: Get author details
- `PUT /api/books/authors/{author_id}`: Update author (admin only)
- `DELETE /api/books/authors/{author_id}`: Delete author (admin only)
### Categories
- `GET /api/books/categories`: List categories
- `POST /api/books/categories`: Create a new category (admin only)
- `GET /api/books/categories/{category_id}`: Get category details
- `PUT /api/books/categories/{category_id}`: Update category (admin only)
- `DELETE /api/books/categories/{category_id}`: Delete category (admin only)
### Orders
- `POST /api/orders`: Create a new order
- `GET /api/orders`: List current user's orders
- `GET /api/orders/admin`: List all orders (admin only)
- `GET /api/orders/{order_id}`: Get order details
- `PUT /api/orders/{order_id}`: Update order
- `DELETE /api/orders/{order_id}`: Cancel order
## Database Schema
The application uses the following database models:
- **User**: User account information
- **Book**: Book details including stock quantity
- **Author**: Author information
- **Category**: Book categories
- **Order**: Order information including status and shipping address
- **OrderItem**: Individual items in an order with quantity and price
## Authentication and Authorization
The API uses JWT tokens for authentication. To access protected endpoints:
1. Register a user or login to get an access token
2. Include the token in the Authorization header of subsequent requests:
`Authorization: Bearer {your_token}`
## Development
### Database Migrations
To create a new migration after modifying models:
```bash
alembic revision --autogenerate -m "Description of changes"
alembic upgrade head
```
### Adding Admin Users
To add an admin user, you can use the API to create a user and then update the `is_admin` field in the database manually, or create a script to do this.

105
alembic.ini Normal file
View File

@ -0,0 +1,105 @@
# 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.
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# SQLite URL example
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
# 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

0
app/__init__.py Normal file
View File

0
app/api/__init__.py Normal file
View File

View File

383
app/api/routes/books.py Normal file
View File

@ -0,0 +1,383 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
from app.db.database import get_db
from app.models.book import Book, Author, Category
from app.models.user import User
from app.api.schemas.book import (
Book as BookSchema,
BookCreate,
BookUpdate,
BookList,
Author as AuthorSchema,
AuthorCreate,
AuthorUpdate,
Category as CategorySchema,
CategoryCreate,
CategoryUpdate,
)
from app.auth.auth import get_current_admin_user
router = APIRouter()
# Book routes
@router.get("", response_model=BookList)
async def get_books(
skip: int = 0,
limit: int = 100,
title: Optional[str] = None,
category_id: Optional[int] = None,
author_id: Optional[int] = None,
db: Session = Depends(get_db),
):
"""
Get a list of books with optional filters
"""
query = db.query(Book)
# Apply filters if provided
if title:
query = query.filter(Book.title.ilike(f"%{title}%"))
if category_id:
query = query.filter(Book.categories.any(Category.id == category_id))
if author_id:
query = query.filter(Book.authors.any(Author.id == author_id))
total = query.count()
books = query.offset(skip).limit(limit).all()
return {"total": total, "items": books}
@router.get("/{book_id}", response_model=BookSchema)
async def get_book(book_id: int, db: Session = Depends(get_db)):
"""
Get a book by ID
"""
book = db.query(Book).filter(Book.id == book_id).first()
if book is None:
raise HTTPException(status_code=404, detail="Book not found")
return book
@router.post("", response_model=BookSchema, status_code=status.HTTP_201_CREATED)
async def create_book(
book: BookCreate,
db: Session = Depends(get_db),
_: User = Depends(get_current_admin_user),
):
"""
Create a new book (admin only)
"""
# Check if book with ISBN already exists
existing_book = db.query(Book).filter(Book.isbn == book.isbn).first()
if existing_book:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Book with this ISBN already exists",
)
# Get authors
authors = db.query(Author).filter(Author.id.in_(book.author_ids)).all()
if len(authors) != len(book.author_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="One or more author IDs not found",
)
# Get categories
categories = db.query(Category).filter(Category.id.in_(book.category_ids)).all()
if len(categories) != len(book.category_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="One or more category IDs not found",
)
# Create new book
db_book = Book(
title=book.title,
isbn=book.isbn,
description=book.description,
price=book.price,
cover_image_url=book.cover_image_url,
publication_date=book.publication_date,
publisher=book.publisher,
language=book.language,
page_count=book.page_count,
stock_quantity=book.stock_quantity,
authors=authors,
categories=categories,
)
db.add(db_book)
db.commit()
db.refresh(db_book)
return db_book
@router.put("/{book_id}", response_model=BookSchema)
async def update_book(
book_id: int,
book: BookUpdate,
db: Session = Depends(get_db),
_: User = Depends(get_current_admin_user),
):
"""
Update a book (admin only)
"""
db_book = db.query(Book).filter(Book.id == book_id).first()
if db_book is None:
raise HTTPException(status_code=404, detail="Book not found")
# Update basic fields if provided
update_data = book.model_dump(exclude_unset=True)
# Handle author_ids separately
if "author_ids" in update_data:
author_ids = update_data.pop("author_ids")
if author_ids:
authors = db.query(Author).filter(Author.id.in_(author_ids)).all()
if len(authors) != len(author_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="One or more author IDs not found",
)
db_book.authors = authors
# Handle category_ids separately
if "category_ids" in update_data:
category_ids = update_data.pop("category_ids")
if category_ids:
categories = db.query(Category).filter(Category.id.in_(category_ids)).all()
if len(categories) != len(category_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="One or more category IDs not found",
)
db_book.categories = categories
# Update the book with the remaining fields
for key, value in update_data.items():
setattr(db_book, key, value)
db.commit()
db.refresh(db_book)
return db_book
@router.delete(
"/{book_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None
)
async def delete_book(
book_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_admin_user),
):
"""
Delete a book (admin only)
"""
db_book = db.query(Book).filter(Book.id == book_id).first()
if db_book is None:
raise HTTPException(status_code=404, detail="Book not found")
db.delete(db_book)
db.commit()
return None
# Author routes
@router.get("/authors", response_model=List[AuthorSchema])
async def get_authors(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
):
"""
Get a list of authors
"""
authors = db.query(Author).offset(skip).limit(limit).all()
return authors
@router.get("/authors/{author_id}", response_model=AuthorSchema)
async def get_author(author_id: int, db: Session = Depends(get_db)):
"""
Get an author by ID
"""
author = db.query(Author).filter(Author.id == author_id).first()
if author is None:
raise HTTPException(status_code=404, detail="Author not found")
return author
@router.post(
"/authors", response_model=AuthorSchema, status_code=status.HTTP_201_CREATED
)
async def create_author(
author: AuthorCreate,
db: Session = Depends(get_db),
_: User = Depends(get_current_admin_user),
):
"""
Create a new author (admin only)
"""
db_author = Author(**author.model_dump())
db.add(db_author)
db.commit()
db.refresh(db_author)
return db_author
@router.put("/authors/{author_id}", response_model=AuthorSchema)
async def update_author(
author_id: int,
author: AuthorUpdate,
db: Session = Depends(get_db),
_: User = Depends(get_current_admin_user),
):
"""
Update an author (admin only)
"""
db_author = db.query(Author).filter(Author.id == author_id).first()
if db_author is None:
raise HTTPException(status_code=404, detail="Author not found")
update_data = author.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(db_author, key, value)
db.commit()
db.refresh(db_author)
return db_author
@router.delete(
"/authors/{author_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None
)
async def delete_author(
author_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_admin_user),
):
"""
Delete an author (admin only)
"""
db_author = db.query(Author).filter(Author.id == author_id).first()
if db_author is None:
raise HTTPException(status_code=404, detail="Author not found")
db.delete(db_author)
db.commit()
return None
# Category routes
@router.get("/categories", response_model=List[CategorySchema])
async def get_categories(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
):
"""
Get a list of categories
"""
categories = db.query(Category).offset(skip).limit(limit).all()
return categories
@router.get("/categories/{category_id}", response_model=CategorySchema)
async def get_category(category_id: int, db: Session = Depends(get_db)):
"""
Get a category by ID
"""
category = db.query(Category).filter(Category.id == category_id).first()
if category is None:
raise HTTPException(status_code=404, detail="Category not found")
return category
@router.post(
"/categories", response_model=CategorySchema, status_code=status.HTTP_201_CREATED
)
async def create_category(
category: CategoryCreate,
db: Session = Depends(get_db),
_: User = Depends(get_current_admin_user),
):
"""
Create a new category (admin only)
"""
# Check if category with the same name already exists
existing_category = (
db.query(Category).filter(Category.name == category.name).first()
)
if existing_category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Category with this name already exists",
)
db_category = Category(**category.model_dump())
db.add(db_category)
db.commit()
db.refresh(db_category)
return db_category
@router.put("/categories/{category_id}", response_model=CategorySchema)
async def update_category(
category_id: int,
category: CategoryUpdate,
db: Session = Depends(get_db),
_: User = Depends(get_current_admin_user),
):
"""
Update a category (admin only)
"""
db_category = db.query(Category).filter(Category.id == category_id).first()
if db_category is None:
raise HTTPException(status_code=404, detail="Category not found")
update_data = category.model_dump(exclude_unset=True)
# If name is being updated, check for uniqueness
if "name" in update_data and update_data["name"] != db_category.name:
existing_category = (
db.query(Category).filter(Category.name == update_data["name"]).first()
)
if existing_category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Category with this name already exists",
)
for key, value in update_data.items():
setattr(db_category, key, value)
db.commit()
db.refresh(db_category)
return db_category
@router.delete(
"/categories/{category_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
)
async def delete_category(
category_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_admin_user),
):
"""
Delete a category (admin only)
"""
db_category = db.query(Category).filter(Category.id == category_id).first()
if db_category is None:
raise HTTPException(status_code=404, detail="Category not found")
db.delete(db_category)
db.commit()
return None

21
app/api/routes/health.py Normal file
View File

@ -0,0 +1,21 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.db.database import get_db
router = APIRouter()
@router.get("/health", status_code=status.HTTP_200_OK)
async def health_check(db: Session = Depends(get_db)):
"""
Health check endpoint to verify API is running and database connection is working
"""
try:
# Execute a simple query to check database connection
db.execute("SELECT 1")
db_status = "healthy"
except Exception:
db_status = "unhealthy"
return {"status": "healthy", "database": db_status}

244
app/api/routes/orders.py Normal file
View File

@ -0,0 +1,244 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import Optional
from app.db.database import get_db
from app.models.order import Order, OrderItem, OrderStatus
from app.models.book import Book
from app.models.user import User
from app.api.schemas.order import (
Order as OrderSchema,
OrderCreate,
OrderUpdate,
OrderList,
)
from app.auth.auth import get_current_active_user, get_current_admin_user
router = APIRouter()
@router.post("", response_model=OrderSchema, status_code=status.HTTP_201_CREATED)
async def create_order(
order: OrderCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
"""
Create a new order
"""
# Validate that all books exist and have enough stock
total_amount = 0
order_items = []
for item in order.items:
book = db.query(Book).filter(Book.id == item.book_id).first()
if not book:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Book with ID {item.book_id} not found",
)
if book.stock_quantity < item.quantity:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Not enough stock for book '{book.title}'. Available: {book.stock_quantity}",
)
# Calculate item total
item_total = item.unit_price * item.quantity
total_amount += item_total
# Create order item
order_items.append(
OrderItem(
book_id=item.book_id,
quantity=item.quantity,
unit_price=item.unit_price,
)
)
# Update book stock
book.stock_quantity -= item.quantity
# Create the order
db_order = Order(
user_id=current_user.id,
total_amount=total_amount,
status=OrderStatus.PENDING,
shipping_address=order.shipping_address,
items=order_items,
)
db.add(db_order)
db.commit()
db.refresh(db_order)
return db_order
@router.get("", response_model=OrderList)
async def get_orders(
skip: int = 0,
limit: int = 100,
status: Optional[OrderStatus] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
"""
Get a list of user's orders with optional status filter
"""
query = db.query(Order).filter(Order.user_id == current_user.id)
# Apply status filter if provided
if status:
query = query.filter(Order.status == status)
total = query.count()
orders = query.order_by(Order.created_at.desc()).offset(skip).limit(limit).all()
return {"total": total, "items": orders}
@router.get("/admin", response_model=OrderList)
async def get_all_orders(
skip: int = 0,
limit: int = 100,
status: Optional[OrderStatus] = None,
user_id: Optional[int] = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_admin_user),
):
"""
Get a list of all orders with optional filters (admin only)
"""
query = db.query(Order)
# Apply filters if provided
if status:
query = query.filter(Order.status == status)
if user_id:
query = query.filter(Order.user_id == user_id)
total = query.count()
orders = query.order_by(Order.created_at.desc()).offset(skip).limit(limit).all()
return {"total": total, "items": orders}
@router.get("/{order_id}", response_model=OrderSchema)
async def get_order(
order_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
"""
Get an order by ID
"""
order = db.query(Order).filter(Order.id == order_id).first()
if order is None:
raise HTTPException(status_code=404, detail="Order not found")
# Check if the order belongs to the current user or if the user is an admin
if order.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to access this order",
)
return order
@router.put("/{order_id}", response_model=OrderSchema)
async def update_order(
order_id: int,
order_update: OrderUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
"""
Update an order. Users can only update their own orders' shipping address
and only if the order is still in 'pending' state.
Admins can update status and shipping address of any order.
"""
db_order = db.query(Order).filter(Order.id == order_id).first()
if db_order is None:
raise HTTPException(status_code=404, detail="Order not found")
# Check permissions
if current_user.is_admin:
# Admin can update any order
update_data = order_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(db_order, key, value)
else:
# Regular users can only update their own orders if they're still pending
if db_order.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to update this order",
)
if db_order.status != OrderStatus.PENDING:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot update order that is not in 'pending' state",
)
# Regular users can only update shipping address
if order_update.shipping_address:
db_order.shipping_address = order_update.shipping_address
db.commit()
db.refresh(db_order)
return db_order
@router.delete(
"/{order_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None
)
async def cancel_order(
order_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
):
"""
Cancel an order. Users can only cancel their own orders
and only if the order is still in 'pending' state.
Admins can cancel any order that hasn't been shipped yet.
"""
db_order = db.query(Order).filter(Order.id == order_id).first()
if db_order is None:
raise HTTPException(status_code=404, detail="Order not found")
# Check permissions
if current_user.is_admin:
# Admin can cancel any order that hasn't been shipped
if db_order.status in [OrderStatus.SHIPPED, OrderStatus.DELIVERED]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot cancel order that has been shipped or delivered",
)
else:
# Regular users can only cancel their own orders if they're still pending
if db_order.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to cancel this order",
)
if db_order.status != OrderStatus.PENDING:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot cancel order that is not in 'pending' state",
)
# Return items to inventory
for item in db_order.items:
book = db.query(Book).filter(Book.id == item.book_id).first()
if book:
book.stock_quantity += item.quantity
# Update order status
db_order.status = OrderStatus.CANCELLED
db.commit()
return None

201
app/api/routes/users.py Normal file
View File

@ -0,0 +1,201 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from datetime import timedelta
from app.db.database import get_db
from app.models.user import User
from app.api.schemas.user import (
User as UserSchema,
UserCreate,
UserUpdate,
Token,
)
from app.auth.auth import (
get_password_hash,
authenticate_user,
create_access_token,
get_current_active_user,
get_current_admin_user,
ACCESS_TOKEN_EXPIRE_MINUTES,
)
router = APIRouter()
@router.post(
"/register", response_model=UserSchema, status_code=status.HTTP_201_CREATED
)
async def register_user(user: UserCreate, db: Session = Depends(get_db)):
"""
Register a new user
"""
# Check if user with the same email already exists
db_user_email = db.query(User).filter(User.email == user.email).first()
if db_user_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# Check if user with the same username already exists
db_user_username = db.query(User).filter(User.username == user.username).first()
if db_user_username:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken",
)
# Create new user
hashed_password = get_password_hash(user.password)
db_user = User(
email=user.email,
username=user.username,
hashed_password=hashed_password,
full_name=user.full_name,
is_active=user.is_active,
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
@router.post("/login", response_model=Token)
async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db),
):
"""
OAuth2 compatible token login, get an access token for future requests
"""
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.get("/me", response_model=UserSchema)
async def read_users_me(current_user: User = Depends(get_current_active_user)):
"""
Get current user information
"""
return current_user
@router.put("/me", response_model=UserSchema)
async def update_user_me(
user: UserUpdate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db),
):
"""
Update current user information
"""
db_user = db.query(User).filter(User.id == current_user.id).first()
update_data = user.model_dump(exclude_unset=True)
# If email is being updated, check for uniqueness
if "email" in update_data and update_data["email"] != db_user.email:
db_user_email = (
db.query(User).filter(User.email == update_data["email"]).first()
)
if db_user_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# Hash the password if it's being updated
if "password" in update_data:
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
for key, value in update_data.items():
setattr(db_user, key, value)
db.commit()
db.refresh(db_user)
return db_user
@router.get("/{user_id}", response_model=UserSchema)
async def read_user(
user_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_admin_user),
):
"""
Get user by ID (admin only)
"""
db_user = db.query(User).filter(User.id == user_id).first()
if db_user is None:
raise HTTPException(status_code=404, detail="User not found")
return db_user
@router.put("/{user_id}", response_model=UserSchema)
async def update_user(
user_id: int,
user: UserUpdate,
db: Session = Depends(get_db),
_: User = Depends(get_current_admin_user),
):
"""
Update user information (admin only)
"""
db_user = db.query(User).filter(User.id == user_id).first()
if db_user is None:
raise HTTPException(status_code=404, detail="User not found")
update_data = user.model_dump(exclude_unset=True)
# If email is being updated, check for uniqueness
if "email" in update_data and update_data["email"] != db_user.email:
db_user_email = (
db.query(User).filter(User.email == update_data["email"]).first()
)
if db_user_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# Hash the password if it's being updated
if "password" in update_data:
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
for key, value in update_data.items():
setattr(db_user, key, value)
db.commit()
db.refresh(db_user)
return db_user
@router.delete(
"/{user_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None
)
async def delete_user(
user_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_admin_user),
):
"""
Delete a user (admin only)
"""
db_user = db.query(User).filter(User.id == user_id).first()
if db_user is None:
raise HTTPException(status_code=404, detail="User not found")
db.delete(db_user)
db.commit()
return None

View File

124
app/api/schemas/book.py Normal file
View File

@ -0,0 +1,124 @@
from pydantic import BaseModel, field_validator
from typing import List, Optional
from datetime import datetime
class AuthorBase(BaseModel):
name: str
biography: Optional[str] = None
birthdate: Optional[datetime] = None
class AuthorCreate(AuthorBase):
pass
class AuthorUpdate(BaseModel):
name: Optional[str] = None
biography: Optional[str] = None
birthdate: Optional[datetime] = None
class Author(AuthorBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class CategoryBase(BaseModel):
name: str
description: Optional[str] = None
class CategoryCreate(CategoryBase):
pass
class CategoryUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class Category(CategoryBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class BookBase(BaseModel):
title: str
isbn: str
description: Optional[str] = None
price: float
cover_image_url: Optional[str] = None
publication_date: Optional[datetime] = None
publisher: Optional[str] = None
language: Optional[str] = None
page_count: Optional[int] = None
stock_quantity: int = 0
@field_validator("price")
def price_must_be_positive(cls, v):
if v <= 0:
raise ValueError("Price must be greater than zero")
return v
@field_validator("stock_quantity")
def stock_quantity_must_be_non_negative(cls, v):
if v < 0:
raise ValueError("Stock quantity must be non-negative")
return v
class BookCreate(BookBase):
author_ids: List[int]
category_ids: List[int]
class BookUpdate(BaseModel):
title: Optional[str] = None
isbn: Optional[str] = None
description: Optional[str] = None
price: Optional[float] = None
cover_image_url: Optional[str] = None
publication_date: Optional[datetime] = None
publisher: Optional[str] = None
language: Optional[str] = None
page_count: Optional[int] = None
stock_quantity: Optional[int] = None
author_ids: Optional[List[int]] = None
category_ids: Optional[List[int]] = None
@field_validator("price")
def price_must_be_positive(cls, v):
if v is not None and v <= 0:
raise ValueError("Price must be greater than zero")
return v
@field_validator("stock_quantity")
def stock_quantity_must_be_non_negative(cls, v):
if v is not None and v < 0:
raise ValueError("Stock quantity must be non-negative")
return v
class Book(BookBase):
id: int
authors: List[Author]
categories: List[Category]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class BookList(BaseModel):
total: int
items: List[Book]

67
app/api/schemas/order.py Normal file
View File

@ -0,0 +1,67 @@
from pydantic import BaseModel, field_validator
from typing import List, Optional
from datetime import datetime
from app.models.order import OrderStatus
class OrderItemBase(BaseModel):
book_id: int
quantity: int
unit_price: float
@field_validator("quantity")
def quantity_must_be_positive(cls, v):
if v <= 0:
raise ValueError("Quantity must be greater than zero")
return v
@field_validator("unit_price")
def unit_price_must_be_positive(cls, v):
if v <= 0:
raise ValueError("Unit price must be greater than zero")
return v
class OrderItemCreate(OrderItemBase):
pass
class OrderItem(OrderItemBase):
id: int
order_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class OrderBase(BaseModel):
shipping_address: str
class OrderCreate(OrderBase):
items: List[OrderItemCreate]
class OrderUpdate(BaseModel):
shipping_address: Optional[str] = None
status: Optional[OrderStatus] = None
class Order(OrderBase):
id: int
user_id: int
total_amount: float
status: OrderStatus
items: List[OrderItem]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class OrderList(BaseModel):
total: int
items: List[Order]

62
app/api/schemas/user.py Normal file
View File

@ -0,0 +1,62 @@
from pydantic import BaseModel, EmailStr, field_validator
from typing import Optional
from datetime import datetime
class UserBase(BaseModel):
email: EmailStr
username: str
full_name: Optional[str] = None
is_active: bool = True
class UserCreate(UserBase):
password: str
@field_validator("password")
def password_must_be_strong(cls, v):
if len(v) < 8:
raise ValueError("Password must be at least 8 characters long")
return v
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
full_name: Optional[str] = None
password: Optional[str] = None
@field_validator("password")
def password_must_be_strong(cls, v):
if v is not None and len(v) < 8:
raise ValueError("Password must be at least 8 characters long")
return v
class UserInDB(UserBase):
id: int
hashed_password: str
is_admin: bool = False
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class User(UserBase):
id: int
is_admin: bool = False
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: Optional[str] = None

0
app/auth/__init__.py Normal file
View File

106
app/auth/auth.py Normal file
View File

@ -0,0 +1,106 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.database import get_db
from app.models.user import User
from app.api.schemas.user import UserInDB
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# OAuth2 password bearer token
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/users/login")
# JWT constants
SECRET_KEY = settings.SECRET_KEY
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = settings.ACCESS_TOKEN_EXPIRE_MINUTES
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against a hash."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Generate a password hash."""
return pwd_context.hash(password)
def get_user(db: Session, username: str) -> Optional[UserInDB]:
"""Get a user by username from the database."""
user = db.query(User).filter(User.username == username).first()
if user:
return UserInDB.model_validate(user.__dict__)
return None
def authenticate_user(db: Session, username: str, password: str) -> Optional[UserInDB]:
"""Authenticate a user with username and password."""
user = get_user(db, username)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token."""
to_encode = data.copy()
expire = datetime.utcnow() + (
expires_delta
if expires_delta
else timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def get_current_user(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
) -> UserInDB:
"""Get the current authenticated user from the token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = get_user(db, username=username)
if user is None:
raise credentials_exception
return user
def get_current_active_user(
current_user: UserInDB = Depends(get_current_user),
) -> UserInDB:
"""Get the current active user."""
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
def get_current_admin_user(
current_user: UserInDB = Depends(get_current_active_user),
) -> UserInDB:
"""Get the current admin user."""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions"
)
return current_user

0
app/core/__init__.py Normal file
View File

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

@ -0,0 +1,46 @@
from pydantic_settings import BaseSettings
from typing import Optional, List
from pydantic import EmailStr, validator
import secrets
from pathlib import Path
class Settings(BaseSettings):
# API Settings
API_V1_STR: str = "/api"
PROJECT_NAME: str = "Online Bookstore API"
# Security
SECRET_KEY: str = secrets.token_urlsafe(32)
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days
# CORS
BACKEND_CORS_ORIGINS: List[str] = ["*"]
# Database
DB_DIR: Path = Path("/app") / "storage" / "db"
DATABASE_URL: Optional[str] = None
@validator("DATABASE_URL", pre=True)
def assemble_db_url(cls, v: Optional[str], values: dict) -> str:
if v:
return v
db_dir = values.get("DB_DIR")
db_dir.mkdir(parents=True, exist_ok=True)
return f"sqlite:///{db_dir}/db.sqlite"
# Email settings
SMTP_TLS: bool = True
SMTP_PORT: Optional[int] = None
SMTP_HOST: Optional[str] = None
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[str] = None
EMAILS_FROM_EMAIL: Optional[EmailStr] = None
EMAILS_FROM_NAME: Optional[str] = None
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()

67
app/core/exceptions.py Normal file
View File

@ -0,0 +1,67 @@
from fastapi import Request, status
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from sqlalchemy.exc import SQLAlchemyError
from typing import Union, Dict, Any
from app.main import app
class BookstoreException(Exception):
"""Base exception for the bookstore application"""
def __init__(
self,
status_code: int,
detail: Union[str, Dict[str, Any]],
headers: Dict[str, str] = None,
):
self.status_code = status_code
self.detail = detail
self.headers = headers
@app.exception_handler(BookstoreException)
async def bookstore_exception_handler(request: Request, exc: BookstoreException):
"""Handler for custom bookstore exceptions"""
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
headers=exc.headers,
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Handler for request validation errors"""
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={
"detail": "Validation error",
"errors": exc.errors(),
},
)
@app.exception_handler(SQLAlchemyError)
async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError):
"""Handler for database errors"""
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"detail": "Database error",
"error": str(exc),
},
)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""Handler for unhandled exceptions"""
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"detail": "Internal server error",
"error": str(exc),
},
)

40
app/core/security.py Normal file
View File

@ -0,0 +1,40 @@
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.models.user import User
from app.models.order import Order
def verify_order_owner_or_admin(
order_id: int,
current_user: User,
db: Session,
) -> Order:
"""
Verify that the user owns the order or is an admin
Args:
order_id: The ID of the order to check
current_user: The current user
db: The database session
Returns:
The order if the user is authorized
Raises:
HTTPException: If the user is not authorized or the order doesn't exist
"""
order = db.query(Order).filter(Order.id == order_id).first()
if order is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Order not found",
)
if order.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to access this order",
)
return order

0
app/db/__init__.py Normal file
View File

27
app/db/database.py Normal file
View File

@ -0,0 +1,27 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from pathlib import Path
# Create database directory if it doesn't exist
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)
Base = declarative_base()
# Dependency to get DB session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

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

@ -0,0 +1,5 @@
from app.models.user import User # noqa
from app.models.book import Book, Author, Category # noqa
from app.models.order import Order, OrderItem, OrderStatus # noqa
# Import all models here so that they are registered with SQLAlchemy

80
app/models/book.py Normal file
View File

@ -0,0 +1,80 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, Text, Table, ForeignKey
from sqlalchemy.orm import relationship
from app.db.database import Base
from datetime import datetime
# Association table for books and categories (many-to-many)
book_category_association = Table(
"book_category",
Base.metadata,
Column("book_id", Integer, ForeignKey("books.id"), primary_key=True),
Column("category_id", Integer, ForeignKey("categories.id"), primary_key=True),
)
# Association table for books and authors (many-to-many)
book_author_association = Table(
"book_author",
Base.metadata,
Column("book_id", Integer, ForeignKey("books.id"), primary_key=True),
Column("author_id", Integer, ForeignKey("authors.id"), primary_key=True),
)
class Book(Base):
__tablename__ = "books"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, index=True, nullable=False)
isbn = Column(String, unique=True, index=True, nullable=False)
description = Column(Text)
price = Column(Float, nullable=False)
cover_image_url = Column(String)
publication_date = Column(DateTime)
publisher = Column(String)
language = Column(String)
page_count = Column(Integer)
stock_quantity = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
authors = relationship(
"Author", secondary=book_author_association, back_populates="books"
)
categories = relationship(
"Category", secondary=book_category_association, back_populates="books"
)
order_items = relationship("OrderItem", back_populates="book")
class Author(Base):
__tablename__ = "authors"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
biography = Column(Text)
birthdate = Column(DateTime)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
books = relationship(
"Book", secondary=book_author_association, back_populates="authors"
)
class Category(Base):
__tablename__ = "categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True, nullable=False)
description = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
books = relationship(
"Book", secondary=book_category_association, back_populates="categories"
)

48
app/models/order.py Normal file
View File

@ -0,0 +1,48 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Enum
from sqlalchemy.orm import relationship
import enum
from app.db.database import Base
from datetime import datetime
class OrderStatus(str, enum.Enum):
PENDING = "pending"
PROCESSING = "processing"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
total_amount = Column(Float, nullable=False)
status = Column(Enum(OrderStatus), default=OrderStatus.PENDING)
shipping_address = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="orders")
items = relationship(
"OrderItem", back_populates="order", cascade="all, delete-orphan"
)
class OrderItem(Base):
__tablename__ = "order_items"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
book_id = Column(Integer, ForeignKey("books.id"), nullable=False)
quantity = Column(Integer, nullable=False)
unit_price = Column(Float, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
order = relationship("Order", back_populates="items")
book = relationship("Book", back_populates="order_items")

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

@ -0,0 +1,22 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.orm import relationship
from app.db.database import Base
from datetime import datetime
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
orders = relationship("Order", back_populates="user", cascade="all, delete-orphan")

32
main.py Normal file
View File

@ -0,0 +1,32 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
# Import routers
from app.api.routes import books, users, orders, health
# Create FastAPI app
app = FastAPI(
title="Online Bookstore API",
description="API for an online bookstore to manage books, users, and orders",
version="1.0.0",
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, replace with specific origins
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(health.router, tags=["health"])
app.include_router(books.router, prefix="/api/books", tags=["books"])
app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(orders.router, prefix="/api/orders", tags=["orders"])
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

83
migrations/env.py Normal file
View File

@ -0,0 +1,83 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Import Base and models for Alembic
from app.db.database import Base
from app.models import * # noqa
# 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()

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

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,204 @@
"""Initial migration
Revision ID: 4a1fb28d1c5e
Revises:
Create Date: 2023-09-25 15:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "4a1fb28d1c5e"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create users table
op.create_table(
"users",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("email", sa.String(), nullable=False),
sa.Column("username", sa.String(), nullable=False),
sa.Column("hashed_password", sa.String(), nullable=False),
sa.Column("full_name", sa.String(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.Column("is_admin", sa.Boolean(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False)
op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True)
# Create authors table
op.create_table(
"authors",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("biography", sa.Text(), nullable=True),
sa.Column("birthdate", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_authors_id"), "authors", ["id"], unique=False)
op.create_index(op.f("ix_authors_name"), "authors", ["name"], unique=False)
# Create categories table
op.create_table(
"categories",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_categories_id"), "categories", ["id"], unique=False)
op.create_index(op.f("ix_categories_name"), "categories", ["name"], unique=True)
# Create books table
op.create_table(
"books",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("title", sa.String(), nullable=False),
sa.Column("isbn", sa.String(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("price", sa.Float(), nullable=False),
sa.Column("cover_image_url", sa.String(), nullable=True),
sa.Column("publication_date", sa.DateTime(), nullable=True),
sa.Column("publisher", sa.String(), nullable=True),
sa.Column("language", sa.String(), nullable=True),
sa.Column("page_count", sa.Integer(), nullable=True),
sa.Column("stock_quantity", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_books_id"), "books", ["id"], unique=False)
op.create_index(op.f("ix_books_isbn"), "books", ["isbn"], unique=True)
op.create_index(op.f("ix_books_title"), "books", ["title"], unique=False)
# Create orders table
op.create_table(
"orders",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("total_amount", sa.Float(), nullable=False),
sa.Column(
"status",
sa.Enum(
"pending",
"processing",
"shipped",
"delivered",
"cancelled",
name="orderstatus",
),
nullable=True,
),
sa.Column("shipping_address", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_orders_id"), "orders", ["id"], unique=False)
# Create order_items table
op.create_table(
"order_items",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("order_id", sa.Integer(), nullable=False),
sa.Column("book_id", sa.Integer(), nullable=False),
sa.Column("quantity", sa.Integer(), nullable=False),
sa.Column("unit_price", sa.Float(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["book_id"],
["books.id"],
),
sa.ForeignKeyConstraint(
["order_id"],
["orders.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_order_items_id"), "order_items", ["id"], unique=False)
# Create association tables
op.create_table(
"book_author",
sa.Column("book_id", sa.Integer(), nullable=False),
sa.Column("author_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["author_id"],
["authors.id"],
),
sa.ForeignKeyConstraint(
["book_id"],
["books.id"],
),
sa.PrimaryKeyConstraint("book_id", "author_id"),
)
op.create_table(
"book_category",
sa.Column("book_id", sa.Integer(), nullable=False),
sa.Column("category_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["book_id"],
["books.id"],
),
sa.ForeignKeyConstraint(
["category_id"],
["categories.id"],
),
sa.PrimaryKeyConstraint("book_id", "category_id"),
)
def downgrade() -> None:
# Drop association tables
op.drop_table("book_category")
op.drop_table("book_author")
# Drop order_items table
op.drop_index(op.f("ix_order_items_id"), table_name="order_items")
op.drop_table("order_items")
# Drop orders table
op.drop_index(op.f("ix_orders_id"), table_name="orders")
op.drop_table("orders")
# Drop books table
op.drop_index(op.f("ix_books_title"), table_name="books")
op.drop_index(op.f("ix_books_isbn"), table_name="books")
op.drop_index(op.f("ix_books_id"), table_name="books")
op.drop_table("books")
# Drop categories table
op.drop_index(op.f("ix_categories_name"), table_name="categories")
op.drop_index(op.f("ix_categories_id"), table_name="categories")
op.drop_table("categories")
# Drop authors table
op.drop_index(op.f("ix_authors_name"), table_name="authors")
op.drop_index(op.f("ix_authors_id"), table_name="authors")
op.drop_table("authors")
# Drop users table
op.drop_index(op.f("ix_users_username"), table_name="users")
op.drop_index(op.f("ix_users_id"), table_name="users")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_table("users")

10
requirements.txt Normal file
View File

@ -0,0 +1,10 @@
fastapi>=0.103.1
uvicorn>=0.23.2
sqlalchemy>=2.0.20
alembic>=1.12.0
pydantic>=2.3.0
passlib[bcrypt]>=1.7.4
python-jose[cryptography]>=3.3.0
python-multipart>=0.0.6
email-validator>=2.0.0
ruff>=0.0.290