Build e-commerce API with FastAPI and SQLite

- Implemented user authentication with JWT tokens
- Created product management endpoints
- Added shopping cart functionality
- Implemented order management system
- Setup database models with SQLAlchemy
- Created alembic migrations
- Added health check endpoint

generated with BackendIM... (backend.im)
This commit is contained in:
Automated Action 2025-05-13 22:46:42 +00:00
parent 7d36fcf43a
commit 4458f5320b
36 changed files with 1602 additions and 2 deletions

View File

@ -1,3 +1,89 @@
# FastAPI Application
# E-Commerce API
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform.
A RESTful API for e-commerce applications built with FastAPI and SQLite. This API provides endpoints for user authentication, product management, shopping cart functionality, and order processing.
## Features
- User authentication with JWT tokens
- Product catalog with filtering capabilities
- Shopping cart functionality
- Order management and processing
- Admin routes for managing products and orders
- Data persistence with SQLite
- Alembic migrations for database version control
## API Endpoints
### Authentication
- `POST /api/v1/auth/register` - Register a new user
- `POST /api/v1/auth/login` - Login and get access token
### Users
- `GET /api/v1/users/me` - Get current user information
- `PUT /api/v1/users/me` - Update current user information
- `GET /api/v1/users/{user_id}` - Get user by ID (admin or self only)
- `GET /api/v1/users/` - List all users (admin only)
- `DELETE /api/v1/users/{user_id}` - Delete a user (admin only)
### Products
- `GET /api/v1/products/` - List all active products with filtering options
- `POST /api/v1/products/` - Create a new product (admin only)
- `GET /api/v1/products/{product_id}` - Get product details
- `PUT /api/v1/products/{product_id}` - Update a product (admin only)
- `DELETE /api/v1/products/{product_id}` - Soft delete a product (admin only)
### Cart
- `GET /api/v1/cart/` - Get current user's cart items
- `POST /api/v1/cart/` - Add item to cart
- `PUT /api/v1/cart/{cart_item_id}` - Update cart item quantity
- `DELETE /api/v1/cart/{cart_item_id}` - Remove item from cart
- `DELETE /api/v1/cart/` - Clear cart
### Orders
- `GET /api/v1/orders/` - List user's orders (or all orders for admin)
- `POST /api/v1/orders/` - Create a new order from cart or specified items
- `GET /api/v1/orders/{order_id}` - Get order details with items
- `PUT /api/v1/orders/{order_id}/status` - Update order status (admin only)
- `DELETE /api/v1/orders/{order_id}` - Cancel a pending order
### Health Check
- `GET /health` - Check API health status
## Getting Started
### Prerequisites
- Python 3.8 or higher
### Installation
1. Clone the repository
```
git clone <repository-url>
```
2. Install dependencies
```
pip install -r requirements.txt
```
3. Initialize the database
```
alembic upgrade head
```
4. Run the server
```
uvicorn main:app --reload
```
## API Documentation
When the server is running, you can access the interactive API documentation at:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## Environment Variables
You can customize the following settings in the `app/core/config.py` file:
- `SECRET_KEY`: Secret key for JWT token generation
- `ACCESS_TOKEN_EXPIRE_MINUTES`: Token expiration time
- `BACKEND_CORS_ORIGINS`: CORS origin settings

84
alembic.ini Normal file
View File

@ -0,0 +1,84 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration files
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s
# timezone to use when rendering the date
# within the migration file as well as the filename.
# 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 alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat alembic/versions
# 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
# 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
alembic/__init__.py Normal file
View File

77
alembic/env.py Normal file
View File

@ -0,0 +1,77 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# 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.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
from app.models.base import Base
from app.models import User, Product, CartItem, Order, OrderItem
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():
"""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():
"""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:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
alembic/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():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,112 @@
"""initial migration
Revision ID: 20250513_000001
Revises:
Create Date: 2025-05-13 00:00:01.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '20250513_000001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Create users table
op.create_table(
'users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('password', sa.String(), nullable=False),
sa.Column('full_name', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('is_admin', sa.Boolean(), nullable=True, default=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), 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)
# Create products table
op.create_table(
'products',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('price', sa.Float(), nullable=False),
sa.Column('stock', sa.Integer(), nullable=False, default=0),
sa.Column('image_url', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_products_id'), 'products', ['id'], unique=False)
op.create_index(op.f('ix_products_name'), 'products', ['name'], 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.String(), nullable=False, default='pending'),
sa.Column('shipping_address', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), 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('product_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(timezone=True), server_default=sa.func.now(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=True),
sa.ForeignKeyConstraint(['order_id'], ['orders.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_order_items_id'), 'order_items', ['id'], unique=False)
# Create cart_items table
op.create_table(
'cart_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False, default=1),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=True),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_cart_items_id'), 'cart_items', ['id'], unique=False)
def downgrade():
op.drop_index(op.f('ix_cart_items_id'), table_name='cart_items')
op.drop_table('cart_items')
op.drop_index(op.f('ix_order_items_id'), table_name='order_items')
op.drop_table('order_items')
op.drop_index(op.f('ix_orders_id'), table_name='orders')
op.drop_table('orders')
op.drop_index(op.f('ix_products_name'), table_name='products')
op.drop_index(op.f('ix_products_id'), table_name='products')
op.drop_table('products')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_table('users')

0
app/__init__.py Normal file
View File

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

53
app/api/deps.py Normal file
View File

@ -0,0 +1,53 @@
from typing import Generator, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app import models, schemas
from app.core import security
from app.core.config import settings
from app.db.session import get_db
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)
) -> models.User:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
token_data = schemas.TokenPayload(**payload)
except (JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)
user = db.query(models.User).filter(models.User.id == token_data.sub).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return user
def get_current_active_user(
current_user: models.User = Depends(get_current_user),
) -> models.User:
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
def get_current_active_admin(
current_user: models.User = Depends(get_current_user),
) -> models.User:
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
)
return current_user

View File

65
app/api/endpoints/auth.py Normal file
View File

@ -0,0 +1,65 @@
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app import models, schemas
from app.api import deps
from app.core import security
from app.core.config import settings
router = APIRouter()
@router.post("/login", response_model=schemas.Token)
def login_access_token(
db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends()
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests
"""
user = db.query(models.User).filter(models.User.email == form_data.username).first()
if not user or not security.verify_password(form_data.password, user.password):
raise HTTPException(status_code=400, detail="Incorrect email or password")
elif not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return {
"access_token": security.create_access_token(
user.id, expires_delta=access_token_expires
),
"token_type": "bearer",
}
@router.post("/register", response_model=schemas.User)
def register_user(
*,
db: Session = Depends(deps.get_db),
user_in: schemas.UserCreate,
) -> Any:
"""
Register a new user
"""
user = db.query(models.User).filter(models.User.email == user_in.email).first()
if user:
raise HTTPException(
status_code=400,
detail="A user with this email already exists.",
)
hashed_password = security.get_password_hash(user_in.password)
db_user = models.User(
email=user_in.email,
password=hashed_password,
full_name=user_in.full_name,
is_active=user_in.is_active,
is_admin=user_in.is_admin,
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user

161
app/api/endpoints/cart.py Normal file
View File

@ -0,0 +1,161 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload
from app import models, schemas
from app.api import deps
router = APIRouter()
@router.get("/", response_model=List[schemas.CartItemWithProduct])
def read_cart_items(
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve cart items for the current user.
"""
cart_items = (
db.query(models.CartItem)
.filter(models.CartItem.user_id == current_user.id)
.options(joinedload(models.CartItem.product))
.all()
)
return cart_items
@router.post("/", response_model=schemas.CartItem)
def add_cart_item(
*,
db: Session = Depends(deps.get_db),
cart_item_in: schemas.CartItemCreate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Add item to cart.
"""
# Check if product exists and is active
product = db.query(models.Product).filter(
models.Product.id == cart_item_in.product_id,
models.Product.is_active == True
).first()
if not product:
raise HTTPException(
status_code=404,
detail="Product not found or inactive",
)
# Check if product is in stock
if product.stock < cart_item_in.quantity:
raise HTTPException(
status_code=400,
detail=f"Not enough stock available. Only {product.stock} items left.",
)
# Check if item already in cart, update quantity if it is
existing_item = db.query(models.CartItem).filter(
models.CartItem.user_id == current_user.id,
models.CartItem.product_id == cart_item_in.product_id
).first()
if existing_item:
existing_item.quantity += cart_item_in.quantity
db.add(existing_item)
db.commit()
db.refresh(existing_item)
return existing_item
# Create new cart item
cart_item = models.CartItem(
user_id=current_user.id,
product_id=cart_item_in.product_id,
quantity=cart_item_in.quantity,
)
db.add(cart_item)
db.commit()
db.refresh(cart_item)
return cart_item
@router.put("/{cart_item_id}", response_model=schemas.CartItem)
def update_cart_item(
*,
db: Session = Depends(deps.get_db),
cart_item_id: int,
cart_item_in: schemas.CartItemUpdate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Update quantity of cart item.
"""
cart_item = db.query(models.CartItem).filter(
models.CartItem.id == cart_item_id,
models.CartItem.user_id == current_user.id
).first()
if not cart_item:
raise HTTPException(
status_code=404,
detail="Cart item not found",
)
# Check if product has enough stock
product = db.query(models.Product).filter(models.Product.id == cart_item.product_id).first()
if not product or product.stock < cart_item_in.quantity:
raise HTTPException(
status_code=400,
detail=f"Not enough stock available. Only {product.stock if product else 0} items left.",
)
cart_item.quantity = cart_item_in.quantity
db.add(cart_item)
db.commit()
db.refresh(cart_item)
return cart_item
@router.delete("/{cart_item_id}", response_model=schemas.CartItem)
def delete_cart_item(
*,
db: Session = Depends(deps.get_db),
cart_item_id: int,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Delete cart item.
"""
cart_item = db.query(models.CartItem).filter(
models.CartItem.id == cart_item_id,
models.CartItem.user_id == current_user.id
).first()
if not cart_item:
raise HTTPException(
status_code=404,
detail="Cart item not found",
)
db.delete(cart_item)
db.commit()
return cart_item
@router.delete("/", response_model=List[schemas.CartItem])
def clear_cart(
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Clear all items from cart.
"""
cart_items = db.query(models.CartItem).filter(
models.CartItem.user_id == current_user.id
).all()
for item in cart_items:
db.delete(item)
db.commit()
return cart_items

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

@ -0,0 +1,244 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload
from app import models, schemas
from app.api import deps
from app.models.order import OrderStatus
router = APIRouter()
@router.get("/", response_model=List[schemas.Order])
def read_orders(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve orders for current user.
"""
if current_user.is_admin:
orders = db.query(models.Order).offset(skip).limit(limit).all()
else:
orders = (
db.query(models.Order)
.filter(models.Order.user_id == current_user.id)
.offset(skip)
.limit(limit)
.all()
)
return orders
@router.post("/", response_model=schemas.Order)
def create_order(
*,
db: Session = Depends(deps.get_db),
order_in: schemas.OrderCreate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Create new order. Can create from provided items or from user's cart.
"""
# If no items provided, use cart
if not order_in.items or len(order_in.items) == 0:
cart_items = (
db.query(models.CartItem)
.filter(models.CartItem.user_id == current_user.id)
.all()
)
if not cart_items or len(cart_items) == 0:
raise HTTPException(
status_code=400,
detail="Cart is empty and no items provided",
)
# Calculate total and create order
total_amount = 0.0
order_items = []
for cart_item in cart_items:
product = db.query(models.Product).filter(models.Product.id == cart_item.product_id).first()
if not product or not product.is_active:
raise HTTPException(
status_code=400,
detail=f"Product with id {cart_item.product_id} not found or is inactive",
)
if product.stock < cart_item.quantity:
raise HTTPException(
status_code=400,
detail=f"Not enough stock for product '{product.name}'. Requested: {cart_item.quantity}, Available: {product.stock}",
)
item_total = product.price * cart_item.quantity
total_amount += item_total
order_items.append(
models.OrderItem(
product_id=product.id,
quantity=cart_item.quantity,
unit_price=product.price,
)
)
# Update product stock
product.stock -= cart_item.quantity
db.add(product)
# Remove from cart
db.delete(cart_item)
else:
# Create order from provided items
total_amount = 0.0
order_items = []
for item in order_in.items:
product = db.query(models.Product).filter(
models.Product.id == item.product_id,
models.Product.is_active == True
).first()
if not product:
raise HTTPException(
status_code=400,
detail=f"Product with id {item.product_id} not found or is inactive",
)
if product.stock < item.quantity:
raise HTTPException(
status_code=400,
detail=f"Not enough stock for product '{product.name}'. Requested: {item.quantity}, Available: {product.stock}",
)
item_total = product.price * item.quantity
total_amount += item_total
order_items.append(
models.OrderItem(
product_id=product.id,
quantity=item.quantity,
unit_price=product.price,
)
)
# Update product stock
product.stock -= item.quantity
db.add(product)
# Create order
order = models.Order(
user_id=current_user.id,
total_amount=total_amount,
status=OrderStatus.PENDING,
shipping_address=order_in.shipping_address,
)
db.add(order)
db.commit()
db.refresh(order)
# Add order items
for item in order_items:
item.order_id = order.id
db.add(item)
db.commit()
db.refresh(order)
return order
@router.get("/{order_id}", response_model=schemas.OrderWithItems)
def read_order(
*,
db: Session = Depends(deps.get_db),
order_id: int,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get a specific order by id.
"""
order = (
db.query(models.Order)
.options(joinedload(models.Order.items).joinedload(models.OrderItem.product))
.filter(models.Order.id == order_id)
.first()
)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if order.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Not enough permissions")
return order
@router.put("/{order_id}/status", response_model=schemas.Order)
def update_order_status(
*,
db: Session = Depends(deps.get_db),
order_id: int,
status: OrderStatus,
current_user: models.User = Depends(deps.get_current_active_admin),
) -> Any:
"""
Update an order's status. Admin only.
"""
order = db.query(models.Order).filter(models.Order.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
order.status = status
db.add(order)
db.commit()
db.refresh(order)
return order
@router.delete("/{order_id}", response_model=schemas.Order)
def cancel_order(
*,
db: Session = Depends(deps.get_db),
order_id: int,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Cancel an order. Only possible if status is pending.
"""
order = db.query(models.Order).filter(
models.Order.id == order_id
).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if order.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Not enough permissions")
if order.status != OrderStatus.PENDING:
raise HTTPException(
status_code=400,
detail=f"Cannot cancel order with status '{order.status}'. Only pending orders can be cancelled."
)
# Return items to stock
order_items = db.query(models.OrderItem).filter(models.OrderItem.order_id == order.id).all()
for item in order_items:
product = db.query(models.Product).filter(models.Product.id == item.product_id).first()
if product:
product.stock += item.quantity
db.add(product)
order.status = OrderStatus.CANCELLED
db.add(order)
db.commit()
db.refresh(order)
return order

View File

@ -0,0 +1,131 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app import models, schemas
from app.api import deps
router = APIRouter()
@router.get("/", response_model=List[schemas.Product])
def read_products(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
name: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
) -> Any:
"""
Retrieve products with optional filtering.
"""
query = db.query(models.Product).filter(models.Product.is_active == True)
if name:
query = query.filter(models.Product.name.ilike(f"%{name}%"))
if min_price is not None:
query = query.filter(models.Product.price >= min_price)
if max_price is not None:
query = query.filter(models.Product.price <= max_price)
products = query.offset(skip).limit(limit).all()
return products
@router.post("/", response_model=schemas.Product)
def create_product(
*,
db: Session = Depends(deps.get_db),
product_in: schemas.ProductCreate,
current_user: models.User = Depends(deps.get_current_active_admin),
) -> Any:
"""
Create new product.
"""
product = models.Product(
name=product_in.name,
description=product_in.description,
price=product_in.price,
stock=product_in.stock,
image_url=product_in.image_url,
is_active=product_in.is_active,
)
db.add(product)
db.commit()
db.refresh(product)
return product
@router.get("/{product_id}", response_model=schemas.Product)
def read_product(
*,
db: Session = Depends(deps.get_db),
product_id: int,
) -> Any:
"""
Get product by ID.
"""
product = db.query(models.Product).filter(
models.Product.id == product_id,
models.Product.is_active == True
).first()
if not product:
raise HTTPException(
status_code=404, detail="Product not found"
)
return product
@router.put("/{product_id}", response_model=schemas.Product)
def update_product(
*,
db: Session = Depends(deps.get_db),
product_id: int,
product_in: schemas.ProductUpdate,
current_user: models.User = Depends(deps.get_current_active_admin),
) -> Any:
"""
Update a product.
"""
product = db.query(models.Product).filter(models.Product.id == product_id).first()
if not product:
raise HTTPException(
status_code=404,
detail="Product not found",
)
update_data = product_in.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(product, field, value)
db.add(product)
db.commit()
db.refresh(product)
return product
@router.delete("/{product_id}", response_model=schemas.Product)
def delete_product(
*,
db: Session = Depends(deps.get_db),
product_id: int,
current_user: models.User = Depends(deps.get_current_active_admin),
) -> Any:
"""
Delete a product.
"""
product = db.query(models.Product).filter(models.Product.id == product_id).first()
if not product:
raise HTTPException(
status_code=404,
detail="Product not found",
)
# Soft delete by marking as inactive
product.is_active = False
db.add(product)
db.commit()
db.refresh(product)
return product

129
app/api/endpoints/users.py Normal file
View File

@ -0,0 +1,129 @@
from typing import Any, List
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm import Session
from app import models, schemas
from app.api import deps
from app.core.security import get_password_hash
router = APIRouter()
@router.get("/", response_model=List[schemas.User])
def read_users(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_admin),
) -> Any:
"""
Retrieve users.
"""
users = db.query(models.User).offset(skip).limit(limit).all()
return users
@router.get("/me", response_model=schemas.User)
def read_user_me(
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get current user.
"""
return current_user
@router.put("/me", response_model=schemas.User)
def update_user_me(
*,
db: Session = Depends(deps.get_db),
password: str = Body(None),
full_name: str = Body(None),
email: str = Body(None),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Update own user.
"""
current_user_data = jsonable_encoder(current_user)
user_in = schemas.UserUpdate(**current_user_data)
if password is not None:
user_in.password = password
if full_name is not None:
user_in.full_name = full_name
if email is not None:
user_in.email = email
if user_in.password:
hashed_password = get_password_hash(user_in.password)
current_user.password = hashed_password
if user_in.full_name:
current_user.full_name = user_in.full_name
if user_in.email:
# Check if email already exists
user = db.query(models.User).filter(
models.User.email == user_in.email,
models.User.id != current_user.id
).first()
if user:
raise HTTPException(
status_code=400,
detail="A user with this email already exists.",
)
current_user.email = user_in.email
db.add(current_user)
db.commit()
db.refresh(current_user)
return current_user
@router.get("/{user_id}", response_model=schemas.User)
def read_user_by_id(
user_id: int,
current_user: models.User = Depends(deps.get_current_active_user),
db: Session = Depends(deps.get_db),
) -> Any:
"""
Get a specific user by id.
"""
user = db.query(models.User).filter(models.User.id == user_id).first()
if user == current_user:
return user
if not current_user.is_admin:
raise HTTPException(
status_code=400, detail="The user doesn't have enough privileges"
)
if not user:
raise HTTPException(
status_code=404,
detail="The user with this id does not exist in the system",
)
return user
@router.delete("/{user_id}", response_model=schemas.User)
def delete_user(
user_id: int,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_admin),
) -> Any:
"""
Delete a user.
"""
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(
status_code=404,
detail="The user with this id does not exist in the system",
)
if user.id == current_user.id:
raise HTTPException(
status_code=400,
detail="Users cannot delete themselves",
)
db.delete(user)
db.commit()
return user

10
app/api/routes.py Normal file
View File

@ -0,0 +1,10 @@
from fastapi import APIRouter
from app.api.endpoints import users, products, cart, orders, auth
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(products.router, prefix="/products", tags=["products"])
api_router.include_router(cart.router, prefix="/cart", tags=["cart"])
api_router.include_router(orders.router, prefix="/orders", tags=["orders"])

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

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

@ -0,0 +1,36 @@
from typing import Any, Dict, List, Optional, Union
from pathlib import Path
from pydantic import AnyHttpUrl, BaseSettings, validator
class Settings(BaseSettings):
API_V1_STR: str = "/api/v1"
PROJECT_NAME: str = "ECommerce API"
# CORS Settings
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, (list, str)):
return v
raise ValueError(v)
# Security Settings
SECRET_KEY: str = "supersecretkey" # Change in production
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# Database Settings
DB_DIR = Path("/app") / "storage" / "db"
DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite"
class Config:
case_sensitive = True
settings = Settings()

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

@ -0,0 +1,31 @@
from datetime import datetime, timedelta
from typing import Any, Union
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def create_access_token(
subject: Union[str, Any], expires_delta: timedelta = None
) -> str:
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)

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

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

@ -0,0 +1,22 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(
settings.SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False} # Only needed for SQLite
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# Dependency for routes
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

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

@ -0,0 +1,4 @@
from app.models.user import User
from app.models.product import Product
from app.models.cart import CartItem
from app.models.order import Order, OrderItem, OrderStatus

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

@ -0,0 +1,15 @@
from sqlalchemy import Column, Integer, DateTime
from sqlalchemy.sql import func
from app.db.session import Base
class TimestampMixin:
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class BaseModel(Base, TimestampMixin):
__abstract__ = True
id = Column(Integer, primary_key=True, index=True)

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

@ -0,0 +1,15 @@
from sqlalchemy import Column, Integer, ForeignKey
from sqlalchemy.orm import relationship
from app.models.base import BaseModel
class CartItem(BaseModel):
__tablename__ = "cart_items"
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
quantity = Column(Integer, nullable=False, default=1)
user = relationship("User", back_populates="cart_items")
product = relationship("Product", back_populates="cart_items")

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

@ -0,0 +1,37 @@
from sqlalchemy import Column, Integer, ForeignKey, Float, String, Enum
from sqlalchemy.orm import relationship
import enum
from app.models.base import BaseModel
class OrderStatus(str, enum.Enum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
class Order(BaseModel):
__tablename__ = "orders"
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
total_amount = Column(Float, nullable=False)
status = Column(String, default=OrderStatus.PENDING, nullable=False)
shipping_address = Column(String, nullable=True)
user = relationship("User", back_populates="orders")
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
class OrderItem(BaseModel):
__tablename__ = "order_items"
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
quantity = Column(Integer, nullable=False)
unit_price = Column(Float, nullable=False)
order = relationship("Order", back_populates="items")
product = relationship("Product", back_populates="order_items")

18
app/models/product.py Normal file
View File

@ -0,0 +1,18 @@
from sqlalchemy import Column, String, Float, Integer, Text, Boolean
from sqlalchemy.orm import relationship
from app.models.base import BaseModel
class Product(BaseModel):
__tablename__ = "products"
name = Column(String, index=True, nullable=False)
description = Column(Text, nullable=True)
price = Column(Float, nullable=False)
stock = Column(Integer, nullable=False, default=0)
image_url = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
order_items = relationship("OrderItem", back_populates="product")
cart_items = relationship("CartItem", back_populates="product")

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

@ -0,0 +1,17 @@
from sqlalchemy import Boolean, Column, String
from sqlalchemy.orm import relationship
from app.models.base import BaseModel
class User(BaseModel):
__tablename__ = "users"
email = Column(String, unique=True, index=True, nullable=False)
password = Column(String, nullable=False)
full_name = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
orders = relationship("Order", back_populates="user", cascade="all, delete-orphan")
cart_items = relationship("CartItem", back_populates="user", cascade="all, delete-orphan")

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

@ -0,0 +1,5 @@
from app.schemas.user import User, UserCreate, UserInDB, UserUpdate
from app.schemas.product import Product, ProductCreate, ProductUpdate
from app.schemas.cart import CartItem, CartItemCreate, CartItemUpdate, CartItemWithProduct
from app.schemas.order import Order, OrderCreate, OrderItem, OrderWithItems
from app.schemas.token import Token, TokenPayload

34
app/schemas/cart.py Normal file
View File

@ -0,0 +1,34 @@
from typing import List, Optional
from pydantic import BaseModel, Field
from app.schemas.product import Product
class CartItemBase(BaseModel):
product_id: int
quantity: int = Field(..., gt=0)
class CartItemCreate(CartItemBase):
pass
class CartItemUpdate(BaseModel):
quantity: int = Field(..., gt=0)
class CartItemInDBBase(CartItemBase):
id: int
user_id: int
class Config:
orm_mode = True
class CartItem(CartItemInDBBase):
pass
class CartItemWithProduct(CartItemInDBBase):
product: Product

62
app/schemas/order.py Normal file
View File

@ -0,0 +1,62 @@
from typing import List, Optional
from datetime import datetime
from pydantic import BaseModel, Field
from app.models.order import OrderStatus
from app.schemas.product import Product
class OrderItemBase(BaseModel):
product_id: int
quantity: int = Field(..., gt=0)
unit_price: float = Field(..., gt=0)
class OrderItemCreate(OrderItemBase):
pass
class OrderItemInDBBase(OrderItemBase):
id: int
order_id: int
class Config:
orm_mode = True
class OrderItem(OrderItemInDBBase):
pass
class OrderItemWithProduct(OrderItemInDBBase):
product: Product
class OrderBase(BaseModel):
total_amount: float = Field(..., gt=0)
status: OrderStatus = OrderStatus.PENDING
shipping_address: Optional[str] = None
class OrderCreate(BaseModel):
shipping_address: Optional[str] = None
items: List[OrderItemCreate]
class OrderInDBBase(OrderBase):
id: int
user_id: int
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
class Order(OrderInDBBase):
pass
class OrderWithItems(OrderInDBBase):
items: List[OrderItemWithProduct]

36
app/schemas/product.py Normal file
View File

@ -0,0 +1,36 @@
from typing import Optional
from pydantic import BaseModel, Field
class ProductBase(BaseModel):
name: str
description: Optional[str] = None
price: float = Field(..., gt=0)
stock: int = Field(..., ge=0)
image_url: Optional[str] = None
is_active: bool = True
class ProductCreate(ProductBase):
pass
class ProductUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
price: Optional[float] = Field(None, gt=0)
stock: Optional[int] = Field(None, ge=0)
image_url: Optional[str] = None
is_active: Optional[bool] = None
class ProductInDBBase(ProductBase):
id: int
class Config:
orm_mode = True
class Product(ProductInDBBase):
pass

12
app/schemas/token.py Normal file
View File

@ -0,0 +1,12 @@
from typing import Optional
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
class TokenPayload(BaseModel):
sub: Optional[int] = None

36
app/schemas/user.py Normal file
View File

@ -0,0 +1,36 @@
from typing import Optional
from pydantic import BaseModel, EmailStr
class UserBase(BaseModel):
email: EmailStr
full_name: Optional[str] = None
is_active: Optional[bool] = True
is_admin: bool = False
class UserCreate(UserBase):
password: str
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
full_name: Optional[str] = None
password: Optional[str] = None
is_active: Optional[bool] = None
class UserInDBBase(UserBase):
id: int
class Config:
orm_mode = True
class User(UserInDBBase):
pass
class UserInDB(UserInDBBase):
password: str

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

33
main.py Normal file
View File

@ -0,0 +1,33 @@
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import api_router
from app.core.config import settings
app = FastAPI(
title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_STR}/openapi.json"
)
# Set all CORS enabled origins
if settings.BACKEND_CORS_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.API_V1_STR)
@app.get("/health", tags=["Health"])
def health_check():
"""
Health check endpoint
"""
return {"status": "healthy"}
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", reload=True, port=8000)

11
requirements.txt Normal file
View File

@ -0,0 +1,11 @@
fastapi>=0.68.0
uvicorn>=0.15.0
sqlalchemy>=1.4.23
passlib>=1.7.4
bcrypt>=3.2.0
pydantic>=1.8.2
python-jose>=3.3.0
python-multipart>=0.0.5
alembic>=1.7.1
email-validator>=1.1.3
ruff>=0.0.269