Implement Shopping Cart and Checkout API

- Set up FastAPI application structure
- Create database models for User, Product, Cart, CartItem, Order, and OrderItem
- Set up Alembic for database migrations
- Create Pydantic schemas for request/response models
- Implement API endpoints for products, cart operations, and checkout process
- Add health endpoint
- Update README with project details and documentation
This commit is contained in:
Automated Action 2025-05-18 00:00:02 +00:00
parent 98c31ee8fe
commit d2b80dacc4
39 changed files with 2123 additions and 2 deletions

141
README.md
View File

@ -1,3 +1,140 @@
# FastAPI Application # Shopping Cart and Checkout API
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. A FastAPI-based REST API for managing a shopping cart and checkout process.
## Features
- Product management (Create, Read, Update, Delete)
- Shopping cart operations (Add, Update, Remove, Get)
- Checkout process with order creation and payment simulation
- SQLite database with SQLAlchemy ORM
- Alembic migrations for database versioning
## Tech Stack
- Python 3.8+
- FastAPI
- SQLAlchemy
- Pydantic
- SQLite
- Alembic
## Project Structure
```
.
├── alembic.ini # Alembic configuration
├── main.py # FastAPI application entry point
├── app/ # Application package
│ ├── api/ # API endpoints
│ │ ├── api.py # API router
│ │ └── endpoints/ # API endpoint modules
│ │ ├── cart.py # Cart operations
│ │ ├── checkout.py # Checkout process
│ │ ├── health.py # Health check endpoint
│ │ └── products.py # Product operations
│ ├── core/ # Core application code
│ │ └── config.py # Configuration settings
│ ├── crud/ # CRUD operations
│ │ ├── base.py # Base CRUD class
│ │ ├── cart.py # Cart CRUD operations
│ │ ├── order.py # Order CRUD operations
│ │ └── product.py # Product CRUD operations
│ ├── db/ # Database
│ │ └── session.py # DB session configuration
│ ├── models/ # SQLAlchemy models
│ │ ├── base.py # Base model class
│ │ ├── cart.py # Cart and CartItem models
│ │ ├── order.py # Order and OrderItem models
│ │ ├── product.py # Product model
│ │ └── user.py # User model
│ └── schemas/ # Pydantic schemas
│ ├── base.py # Base schema classes
│ ├── cart.py # Cart and CartItem schemas
│ ├── order.py # Order and OrderItem schemas
│ └── product.py # Product schemas
└── migrations/ # Alembic migrations
├── env.py # Alembic environment
├── script.py.mako # Migration script template
└── versions/ # Migration versions
└── 001_initial_database_schema.py # Initial schema
```
## API Endpoints
### Health Check
- `GET /api/v1/health` - Check API health
### Products
- `GET /api/v1/products` - List products (with optional filtering)
- `POST /api/v1/products` - Create a new product
- `GET /api/v1/products/{product_id}` - Get a specific product
- `PUT /api/v1/products/{product_id}` - Update a product
- `DELETE /api/v1/products/{product_id}` - Delete a product
### Cart
- `GET /api/v1/cart` - Get the current cart
- `POST /api/v1/cart/items` - Add an item to the cart
- `PUT /api/v1/cart/items/{item_id}` - Update a cart item
- `DELETE /api/v1/cart/items/{item_id}` - Remove a cart item
- `DELETE /api/v1/cart` - Clear the cart
### Checkout
- `POST /api/v1/checkout` - Create an order from the cart
- `GET /api/v1/checkout` - List user's orders
- `GET /api/v1/checkout/{order_id}` - Get a specific order
- `POST /api/v1/checkout/{order_id}/pay` - Process payment for an order
## Getting Started
### Installation
1. Clone the repository
2. Install dependencies:
```
pip install -r requirements.txt
```
### Database Setup
1. Run migrations to create the database schema:
```
alembic upgrade head
```
### Running the API
1. Start the FastAPI server:
```
uvicorn main:app --reload
```
2. Access the API documentation at `http://localhost:8000/docs`
## Database Migrations
To create a new migration:
```
alembic revision --autogenerate -m "description"
```
To apply migrations:
```
alembic upgrade head
```
## API Documentation
Interactive API documentation is available at `/docs` when the API is running.
## Notes
- In a real-world application, authentication and authorization would be implemented
- The current implementation has a simplified user system (default user ID 1 is used for demonstration)
- Cart session management is implemented using cookies and headers
- The payment process is simulated (no actual payment processing)

106
alembic.ini Normal file
View File

@ -0,0 +1,106 @@
# 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

11
app/api/api.py Normal file
View File

@ -0,0 +1,11 @@
from fastapi import APIRouter
from app.api.endpoints import health, products, cart, checkout
api_router = APIRouter()
# Include the different endpoints
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(products.router, prefix="/products", tags=["products"])
api_router.include_router(cart.router, prefix="/cart", tags=["cart"])
api_router.include_router(checkout.router, prefix="/checkout", tags=["checkout"])

View File

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

@ -0,0 +1,249 @@
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Header, Cookie, status, Response
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.cart import CartItem
from app.schemas.cart import (
Cart as CartSchema,
CartItemCreate,
CartItemUpdate,
CartItemWithProduct,
AddToCartResponse
)
from app.crud import product, cart, cart_item
router = APIRouter()
def get_cart_id(
db: Session = Depends(get_db),
user_id: Optional[int] = None,
session_id: Optional[str] = Cookie(None),
x_session_id: Optional[str] = Header(None)
) -> int:
"""
Get or create an active cart for the current user/session.
This function will:
1. Try to get the active cart for the authenticated user (if logged in)
2. If no user, try to get the active cart for the current session
3. If no active cart exists, create a new one
Returns the cart ID.
"""
# If the session_id is not in cookies, try to get it from headers
current_session_id = session_id or x_session_id
# Get or create an active cart
active_cart = cart.get_or_create_active_cart(
db=db,
user_id=user_id,
session_id=current_session_id
)
return active_cart.id
@router.get("", response_model=CartSchema)
def get_current_cart(
cart_id: int = Depends(get_cart_id),
db: Session = Depends(get_db),
) -> Any:
"""
Get the current active cart with all items.
"""
current_cart = cart.get_cart_with_items(db=db, cart_id=cart_id)
if not current_cart:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Cart not found"
)
return current_cart
@router.post("/items", response_model=AddToCartResponse)
def add_item_to_cart(
*,
db: Session = Depends(get_db),
item_in: CartItemCreate,
cart_id: int = Depends(get_cart_id),
response: Response
) -> Any:
"""
Add an item to the cart.
If the item already exists in the cart, the quantity will be updated.
"""
# Check if product exists and is active
item_product = product.get(db=db, id=item_in.product_id)
if not item_product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {item_in.product_id} not found"
)
if not item_product.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Product with ID {item_in.product_id} is not available"
)
# Check if product is in stock
if item_product.stock_quantity < item_in.quantity:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Not enough stock available. Only {item_product.stock_quantity} items left."
)
# Get the current cart or create a new one
current_cart = cart.get(db=db, id=cart_id)
# Check if the product is already in the cart
existing_item = cart_item.get_by_cart_and_product(
db=db, cart_id=current_cart.id, product_id=item_in.product_id
)
if existing_item:
# Update the quantity
new_quantity = existing_item.quantity + item_in.quantity
# Check stock for the new total quantity
if item_product.stock_quantity < new_quantity:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Not enough stock available. Only {item_product.stock_quantity} items left."
)
item_update = CartItemUpdate(quantity=new_quantity)
updated_item = cart_item.update(db=db, db_obj=existing_item, obj_in=item_update)
# Fetch the updated item with product details
result = db.query(CartItem).filter(CartItem.id == updated_item.id).first()
result.product = item_product
# Set a cookie with the session ID if applicable
if current_cart.session_id:
response.set_cookie(key="session_id", value=current_cart.session_id, httponly=True)
return AddToCartResponse(
cart_id=current_cart.id,
message="Item quantity updated in cart",
cart_item=result
)
else:
# Add the new item to the cart
new_item = cart_item.create_with_cart(
db=db,
obj_in=item_in,
cart_id=current_cart.id,
product_price=float(item_product.price)
)
# Fetch the new item with product details
result = db.query(CartItem).filter(CartItem.id == new_item.id).first()
result.product = item_product
# Set a cookie with the session ID if applicable
if current_cart.session_id:
response.set_cookie(key="session_id", value=current_cart.session_id, httponly=True)
return AddToCartResponse(
cart_id=current_cart.id,
message="Item added to cart",
cart_item=result
)
@router.put("/items/{item_id}", response_model=CartItemWithProduct)
def update_cart_item(
*,
db: Session = Depends(get_db),
item_id: int,
item_in: CartItemUpdate,
cart_id: int = Depends(get_cart_id)
) -> Any:
"""
Update the quantity of an item in the cart.
"""
# Check if the item exists in the current cart
item = db.query(CartItem).filter(
CartItem.id == item_id,
CartItem.cart_id == cart_id
).first()
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Item with ID {item_id} not found in cart"
)
# Check product availability and stock
item_product = product.get(db=db, id=item.product_id)
if not item_product or not item_product.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Product is not available"
)
if item_product.stock_quantity < item_in.quantity:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Not enough stock available. Only {item_product.stock_quantity} items left."
)
# Update the item
updated_item = cart_item.update(db=db, db_obj=item, obj_in=item_in)
# Fetch the updated item with product details
result = db.query(CartItem).filter(CartItem.id == updated_item.id).first()
result.product = item_product
return result
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def remove_cart_item(
*,
db: Session = Depends(get_db),
item_id: int,
cart_id: int = Depends(get_cart_id)
) -> None:
"""
Remove an item from the cart.
"""
# Check if the item exists in the current cart
item = db.query(CartItem).filter(
CartItem.id == item_id,
CartItem.cart_id == cart_id
).first()
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Item with ID {item_id} not found in cart"
)
# Remove the item
cart_item.remove(db=db, id=item_id)
return None
@router.delete("", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def clear_cart(
*,
db: Session = Depends(get_db),
cart_id: int = Depends(get_cart_id)
) -> None:
"""
Remove all items from the cart.
"""
# Get all items in the cart
items = cart_item.get_by_cart(db=db, cart_id=cart_id)
# Remove all items
for item in items:
cart_item.remove(db=db, id=item.id)
return None

View File

@ -0,0 +1,177 @@
from typing import Any, List
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.order import OrderStatus
from app.schemas.order import (
Order as OrderSchema,
OrderCreate,
OrderResponse
)
from app.crud import product, cart, order, order_item
router = APIRouter()
@router.post("", response_model=OrderResponse, status_code=status.HTTP_201_CREATED)
def create_order(
*,
db: Session = Depends(get_db),
order_in: OrderCreate,
user_id: int = 1 # Simplified: In a real app, this would come from auth
) -> Any:
"""
Create a new order from the current cart.
"""
# Get the cart with items
current_cart = cart.get_cart_with_items(db=db, cart_id=order_in.cart_id)
if not current_cart:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Cart not found"
)
# Check if cart is empty
if not current_cart.items:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot checkout with an empty cart"
)
# Check if all products are available and in stock
for item in current_cart.items:
db_product = product.get(db=db, id=item.product_id)
if not db_product or not db_product.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Product {item.product_id} is not available"
)
if db_product.stock_quantity < item.quantity:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Not enough stock for product {db_product.name}. Only {db_product.stock_quantity} available."
)
# Create the order
new_order = order.create_from_cart(
db=db,
obj_in=order_in,
user_id=user_id,
cart=current_cart
)
# Update product stock
for item in current_cart.items:
product.update_stock(
db=db,
product_id=item.product_id,
quantity=-item.quantity # Decrease stock
)
# Mark cart as inactive
cart_update = {"is_active": False}
cart.update(db=db, db_obj=current_cart, obj_in=cart_update)
# Return order response
return OrderResponse(
order_id=new_order.id,
message="Order created successfully",
status=new_order.status.value,
total_amount=new_order.total_amount
)
@router.get("/{order_id}", response_model=OrderSchema)
def get_order(
*,
db: Session = Depends(get_db),
order_id: int,
user_id: int = 1 # Simplified: In a real app, this would come from auth
) -> Any:
"""
Get order by ID.
"""
db_order = order.get_with_items(db=db, order_id=order_id)
if not db_order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Order with ID {order_id} not found"
)
# Check if the order belongs to the user (simplified authorization)
if db_order.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to access this order"
)
return db_order
@router.get("", response_model=List[OrderSchema])
def get_user_orders(
*,
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
user_id: int = 1 # Simplified: In a real app, this would come from auth
) -> Any:
"""
Get all orders for the current user.
"""
orders = order.get_by_user(db=db, user_id=user_id, skip=skip, limit=limit)
# Fetch items for each order
for o in orders:
o.items = order_item.get_by_order(db=db, order_id=o.id)
return orders
@router.post("/{order_id}/pay", response_model=OrderResponse)
def process_payment(
*,
db: Session = Depends(get_db),
order_id: int,
user_id: int = 1 # Simplified: In a real app, this would come from auth
) -> Any:
"""
Process payment for an order (simplified simulation).
"""
db_order = order.get(db=db, id=order_id)
if not db_order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Order with ID {order_id} not found"
)
# Check if the order belongs to the user (simplified authorization)
if db_order.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to access this order"
)
# Check if the order is already paid
if db_order.status != OrderStatus.PENDING:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Order is already {db_order.status.value}"
)
# Simulate payment processing
payment_id = f"PAY-{uuid.uuid4().hex[:10].upper()}"
updated_order = order.process_payment(db=db, order_id=order_id, payment_id=payment_id)
return OrderResponse(
order_id=updated_order.id,
message="Payment processed successfully",
status=updated_order.status.value,
total_amount=updated_order.total_amount
)

View File

@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends
from sqlalchemy.sql import text
from sqlalchemy.orm import Session
from app.db.session import get_db
router = APIRouter()
@router.get("")
def health_check(db: Session = Depends(get_db)):
"""
Health check endpoint to verify the API is running and has database connectivity.
"""
try:
# Check database connection by executing a simple query
db.execute(text("SELECT 1"))
db_status = "healthy"
except Exception as e:
db_status = f"unhealthy: {str(e)}"
return {
"status": "healthy",
"database": db_status
}

View File

@ -0,0 +1,119 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.product import Product as ProductSchema, ProductCreate, ProductUpdate
from app.crud import product
router = APIRouter()
@router.get("", response_model=List[ProductSchema])
def get_products(
*,
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
active_only: bool = True
) -> Any:
"""
Retrieve products.
Optional filtering by:
- search: search term in product name
- active_only: if True, returns only active products
"""
if search:
products = product.search_by_name(db, name=search, skip=skip, limit=limit)
elif active_only:
products = product.get_active(db, skip=skip, limit=limit)
else:
products = product.get_multi(db, skip=skip, limit=limit)
return products
@router.post("", response_model=ProductSchema, status_code=status.HTTP_201_CREATED)
def create_product(
*,
db: Session = Depends(get_db),
product_in: ProductCreate
) -> Any:
"""
Create new product.
"""
# Check if product with the same SKU already exists
db_product = product.get_by_sku(db, sku=product_in.sku)
if db_product:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Product with SKU {product_in.sku} already exists"
)
return product.create(db=db, obj_in=product_in)
@router.get("/{product_id}", response_model=ProductSchema)
def get_product(
*,
db: Session = Depends(get_db),
product_id: int
) -> Any:
"""
Get product by ID.
"""
db_product = product.get(db=db, id=product_id)
if not db_product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {product_id} not found"
)
return db_product
@router.put("/{product_id}", response_model=ProductSchema)
def update_product(
*,
db: Session = Depends(get_db),
product_id: int,
product_in: ProductUpdate
) -> Any:
"""
Update a product.
"""
db_product = product.get(db=db, id=product_id)
if not db_product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {product_id} not found"
)
# If updating SKU, check if the new SKU already exists in another product
if product_in.sku and product_in.sku != db_product.sku:
existing_product = product.get_by_sku(db, sku=product_in.sku)
if existing_product and existing_product.id != product_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Product with SKU {product_in.sku} already exists"
)
return product.update(db=db, db_obj=db_product, obj_in=product_in)
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_product(
*,
db: Session = Depends(get_db),
product_id: int
) -> None:
"""
Delete a product.
"""
db_product = product.get(db=db, id=product_id)
if not db_product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {product_id} not found"
)
product.remove(db=db, id=product_id)
return None

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

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

@ -0,0 +1,34 @@
import os
from pathlib import Path
from typing import List
from pydantic import AnyHttpUrl
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
API_V1_STR: str = "/api/v1"
PROJECT_NAME: str = "Shopping Cart and Checkout API"
# CORS
CORS_ORIGINS: List[AnyHttpUrl] = []
# Database
DB_DIR: Path = Path("/app") / "storage" / "db"
# JWT
SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-for-development-only")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# Cart settings
CART_EXPIRATION_HOURS: int = 24 * 7 # Default cart expiration time (1 week)
class Config:
env_file = ".env"
settings = Settings()
# Ensure the database directory exists
settings.DB_DIR.mkdir(parents=True, exist_ok=True)

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

@ -0,0 +1,4 @@
# Import all CRUD operations to make them available throughout the app
from app.crud.product import product # noqa: F401
from app.crud.cart import cart, cart_item # noqa: F401
from app.crud.order import order, order_item # noqa: F401

85
app/crud/base.py Normal file
View File

@ -0,0 +1,85 @@
from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.db.session import Base
ModelType = TypeVar("ModelType", bound=Base)
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
"""
CRUD base class with default methods to Create, Read, Update, Delete (CRUD).
**Parameters**
* `model`: SQLAlchemy model class
* `schema`: Pydantic model (schema) class
"""
def __init__(self, model: Type[ModelType]):
"""
Initialize with SQLAlchemy model.
"""
self.model = model
def get(self, db: Session, id: int) -> Optional[ModelType]:
"""
Get a record by ID.
"""
return db.query(self.model).filter(self.model.id == id).first()
def get_multi(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[ModelType]:
"""
Get multiple records.
"""
return db.query(self.model).offset(skip).limit(limit).all()
def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
"""
Create a new record.
"""
obj_in_data = jsonable_encoder(obj_in)
db_obj = self.model(**obj_in_data)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self,
db: Session,
*,
db_obj: ModelType,
obj_in: Union[UpdateSchemaType, Dict[str, Any]]
) -> ModelType:
"""
Update a record.
"""
obj_data = jsonable_encoder(db_obj)
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
for field in obj_data:
if field in update_data:
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def remove(self, db: Session, *, id: int) -> ModelType:
"""
Remove a record.
"""
obj = db.query(self.model).get(id)
db.delete(obj)
db.commit()
return obj

122
app/crud/cart.py Normal file
View File

@ -0,0 +1,122 @@
from typing import List, Optional
from datetime import datetime, timedelta
from uuid import uuid4
from sqlalchemy.orm import Session, joinedload
from app.models.cart import Cart, CartItem
from app.schemas.cart import CartCreate, CartUpdate, CartItemCreate, CartItemUpdate
from app.core.config import settings
from app.crud.base import CRUDBase
class CRUDCartItem(CRUDBase[CartItem, CartItemCreate, CartItemUpdate]):
"""CRUD operations for CartItem model."""
def create_with_cart(
self, db: Session, *, obj_in: CartItemCreate, cart_id: int, product_price: float
) -> CartItem:
"""Create a new cart item with cart ID and product price."""
cart_item = CartItem(
cart_id=cart_id,
product_id=obj_in.product_id,
quantity=obj_in.quantity,
unit_price=product_price
)
db.add(cart_item)
db.commit()
db.refresh(cart_item)
return cart_item
def get_by_cart_and_product(
self, db: Session, *, cart_id: int, product_id: int
) -> Optional[CartItem]:
"""Get a cart item by cart ID and product ID."""
return db.query(CartItem).filter(
CartItem.cart_id == cart_id,
CartItem.product_id == product_id
).first()
def get_by_cart(self, db: Session, *, cart_id: int) -> List[CartItem]:
"""Get all items in a cart."""
return db.query(CartItem).filter(CartItem.cart_id == cart_id).all()
class CRUDCart(CRUDBase[Cart, CartCreate, CartUpdate]):
"""CRUD operations for Cart model."""
def create_with_owner(
self, db: Session, *, obj_in: CartCreate, user_id: Optional[int] = None
) -> Cart:
"""Create a new cart with owner."""
session_id = None if user_id else str(uuid4())
expires_at = datetime.utcnow() + timedelta(hours=settings.CART_EXPIRATION_HOURS)
cart = Cart(
user_id=user_id,
session_id=session_id,
is_active=True,
expires_at=expires_at
)
db.add(cart)
db.commit()
db.refresh(cart)
return cart
def get_active_by_user(self, db: Session, *, user_id: int) -> Optional[Cart]:
"""Get the active cart for a user."""
return db.query(Cart).filter(
Cart.user_id == user_id,
Cart.is_active
).first()
def get_active_by_session(self, db: Session, *, session_id: str) -> Optional[Cart]:
"""Get the active cart for a session."""
return db.query(Cart).filter(
Cart.session_id == session_id,
Cart.is_active
).first()
def get_or_create_active_cart(
self, db: Session, *, user_id: Optional[int] = None, session_id: Optional[str] = None
) -> Cart:
"""Get or create an active cart for a user or session."""
# Try to get existing active cart
cart = None
if user_id:
cart = self.get_active_by_user(db, user_id=user_id)
elif session_id:
cart = self.get_active_by_session(db, session_id=session_id)
# Create new cart if none exists
if not cart:
obj_in = CartCreate(user_id=user_id, session_id=session_id)
cart = self.create_with_owner(db, obj_in=obj_in, user_id=user_id)
return cart
def get_cart_with_items(self, db: Session, *, cart_id: int) -> Optional[Cart]:
"""Get a cart with all its items and product details."""
return db.query(Cart).options(
joinedload(Cart.items).joinedload(CartItem.product)
).filter(Cart.id == cart_id).first()
def clean_expired_carts(self, db: Session) -> int:
"""Clean up expired carts. Returns count of removed carts."""
now = datetime.utcnow()
expired_carts = db.query(Cart).filter(
Cart.expires_at < now,
Cart.is_active
).all()
count = len(expired_carts)
for cart in expired_carts:
cart.is_active = False
db.add(cart)
db.commit()
return count
cart = CRUDCart(Cart)
cart_item = CRUDCartItem(CartItem)

105
app/crud/order.py Normal file
View File

@ -0,0 +1,105 @@
from typing import List, Optional, Any
from datetime import datetime
from sqlalchemy.orm import Session, joinedload
from app.models.order import Order, OrderItem, OrderStatus
from app.models.cart import Cart, CartItem
from app.schemas.order import OrderCreate, OrderUpdate, OrderItemCreate
from app.crud.base import CRUDBase
class CRUDOrderItem(CRUDBase[OrderItem, OrderItemCreate, Any]):
"""CRUD operations for OrderItem model."""
def create_from_cart_item(
self, db: Session, *, order_id: int, cart_item: CartItem
) -> OrderItem:
"""Create a new order item from a cart item."""
order_item = OrderItem(
order_id=order_id,
product_id=cart_item.product_id,
quantity=cart_item.quantity,
unit_price=cart_item.unit_price
)
db.add(order_item)
db.commit()
db.refresh(order_item)
return order_item
def get_by_order(self, db: Session, *, order_id: int) -> List[OrderItem]:
"""Get all items in an order."""
return db.query(OrderItem).filter(OrderItem.order_id == order_id).all()
class CRUDOrder(CRUDBase[Order, OrderCreate, OrderUpdate]):
"""CRUD operations for Order model."""
def create_from_cart(
self, db: Session, *, obj_in: OrderCreate, user_id: int, cart: Cart
) -> Order:
"""Create a new order from a cart."""
# Calculate total amount from cart items
total_amount = sum(item.quantity * item.unit_price for item in cart.items)
# Create order
order = Order(
user_id=user_id,
status=OrderStatus.PENDING,
total_amount=total_amount,
shipping_address=obj_in.shipping_address,
payment_method=obj_in.payment_method,
notes=obj_in.notes,
tracking_number=None,
payment_id=None,
paid_at=None
)
db.add(order)
db.commit()
db.refresh(order)
# Create order items from cart items
order_items = []
for cart_item in cart.items:
order_item = OrderItem(
order_id=order.id,
product_id=cart_item.product_id,
quantity=cart_item.quantity,
unit_price=cart_item.unit_price
)
db.add(order_item)
order_items.append(order_item)
db.commit()
return order
def get_by_user(self, db: Session, *, user_id: int, skip: int = 0, limit: int = 100) -> List[Order]:
"""Get all orders for a user."""
return db.query(Order).filter(Order.user_id == user_id).offset(skip).limit(limit).all()
def get_with_items(self, db: Session, *, order_id: int) -> Optional[Order]:
"""Get an order with all its items and product details."""
return db.query(Order).options(
joinedload(Order.items).joinedload(OrderItem.product)
).filter(Order.id == order_id).first()
def process_payment(self, db: Session, *, order_id: int, payment_id: str) -> Order:
"""Process a payment for an order."""
order = self.get(db=db, id=order_id)
if not order:
return None
order.status = OrderStatus.PAID
order.payment_id = payment_id
order.paid_at = datetime.utcnow()
db.add(order)
db.commit()
db.refresh(order)
return order
order = CRUDOrder(Order)
order_item = CRUDOrderItem(OrderItem)

42
app/crud/product.py Normal file
View File

@ -0,0 +1,42 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.models.product import Product
from app.schemas.product import ProductCreate, ProductUpdate
from app.crud.base import CRUDBase
class CRUDProduct(CRUDBase[Product, ProductCreate, ProductUpdate]):
"""CRUD operations for Product model."""
def get_by_sku(self, db: Session, *, sku: str) -> Optional[Product]:
"""Get a product by SKU."""
return db.query(Product).filter(Product.sku == sku).first()
def get_active(self, db: Session, *, skip: int = 0, limit: int = 100) -> List[Product]:
"""Get active products only."""
return db.query(Product).filter(Product.is_active).offset(skip).limit(limit).all()
def search_by_name(self, db: Session, *, name: str, skip: int = 0, limit: int = 100) -> List[Product]:
"""Search products by name."""
return (
db.query(Product)
.filter(Product.name.ilike(f"%{name}%"))
.filter(Product.is_active)
.offset(skip)
.limit(limit)
.all()
)
def update_stock(self, db: Session, *, product_id: int, quantity: int) -> Optional[Product]:
"""Update product stock quantity."""
product = self.get(db, id=product_id)
if product:
product.stock_quantity = max(0, product.stock_quantity + quantity)
db.add(product)
db.commit()
db.refresh(product)
return product
product = CRUDProduct(Product)

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

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

@ -0,0 +1,35 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# Database URL configuration
DB_DIR = settings.DB_DIR
DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite"
# Create SQLAlchemy engine
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False} # Only needed for SQLite
)
# Create session class
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create base class for models
Base = declarative_base()
def get_db():
"""
Dependency function to get DB session.
This will be used in FastAPI dependency injection system.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()

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

@ -0,0 +1,6 @@
# Import all models to make them available for Alembic
from app.models.base import BaseModel, TimestampMixin # noqa: F401
from app.models.user import User # noqa: F401
from app.models.product import Product # noqa: F401
from app.models.cart import Cart, CartItem # noqa: F401
from app.models.order import Order, OrderItem, OrderStatus, PaymentMethod # noqa: F401

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

@ -0,0 +1,28 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, Integer
from sqlalchemy.ext.declarative import declared_attr
from app.db.session import Base
class TimestampMixin:
"""Mixin to add created_at and updated_at timestamps to models."""
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(
DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=False
)
class BaseModel(Base):
"""Base model for all models."""
__abstract__ = True
id = Column(Integer, primary_key=True, index=True)
@declared_attr
def __tablename__(cls) -> str:
"""Generate __tablename__ automatically as lowercase of class name."""
return cls.__name__.lower()

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

@ -0,0 +1,48 @@
from sqlalchemy import Column, String, Integer, Float, ForeignKey, Boolean, DateTime
from sqlalchemy.orm import relationship
from datetime import datetime, timedelta
from app.core.config import settings
from app.models.base import BaseModel, TimestampMixin
class Cart(BaseModel, TimestampMixin):
"""Shopping cart model."""
user_id = Column(Integer, ForeignKey("user.id"), nullable=True)
session_id = Column(String, index=True, nullable=True) # For non-authenticated users
is_active = Column(Boolean, default=True)
expires_at = Column(
DateTime,
default=lambda: datetime.utcnow() + timedelta(hours=settings.CART_EXPIRATION_HOURS)
)
# Relationships
user = relationship("User", back_populates="carts")
items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan")
@property
def total_price(self):
"""Calculate the total price of all items in the cart."""
return sum(item.subtotal for item in self.items)
@property
def total_items(self):
"""Calculate the total number of items in the cart."""
return sum(item.quantity for item in self.items)
class CartItem(BaseModel, TimestampMixin):
"""Shopping cart item model."""
cart_id = Column(Integer, ForeignKey("cart.id"), nullable=False)
product_id = Column(Integer, ForeignKey("product.id"), nullable=False)
quantity = Column(Integer, nullable=False, default=1)
unit_price = Column(Float, nullable=False) # Price at the time of adding to cart
# Relationships
cart = relationship("Cart", back_populates="items")
product = relationship("Product", back_populates="cart_items")
@property
def subtotal(self):
"""Calculate the subtotal for this cart item."""
return self.quantity * self.unit_price

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

@ -0,0 +1,66 @@
from sqlalchemy import Column, String, Integer, Float, ForeignKey, Enum, Text, DateTime
from sqlalchemy.orm import relationship
import enum
from app.models.base import BaseModel, TimestampMixin
class OrderStatus(str, enum.Enum):
"""Enum for order status."""
PENDING = "pending"
PAID = "paid"
PROCESSING = "processing"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
REFUNDED = "refunded"
class PaymentMethod(str, enum.Enum):
"""Enum for payment methods."""
CREDIT_CARD = "credit_card"
DEBIT_CARD = "debit_card"
PAYPAL = "paypal"
BANK_TRANSFER = "bank_transfer"
CASH_ON_DELIVERY = "cash_on_delivery"
class Order(BaseModel, TimestampMixin):
"""Order model for completed purchases."""
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
status = Column(
Enum(OrderStatus),
default=OrderStatus.PENDING,
nullable=False
)
total_amount = Column(Float, nullable=False)
shipping_address = Column(Text, nullable=False)
tracking_number = Column(String, nullable=True)
payment_method = Column(
Enum(PaymentMethod),
nullable=False
)
payment_id = Column(String, nullable=True) # ID from payment processor
paid_at = Column(DateTime, nullable=True)
notes = Column(Text, nullable=True)
# Relationships
user = relationship("User", back_populates="orders")
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
class OrderItem(BaseModel, TimestampMixin):
"""Order item model for items in an order."""
order_id = Column(Integer, ForeignKey("order.id"), nullable=False)
product_id = Column(Integer, ForeignKey("product.id"), nullable=False)
quantity = Column(Integer, nullable=False)
unit_price = Column(Float, nullable=False) # Price at the time of order
# Relationships
order = relationship("Order", back_populates="items")
product = relationship("Product", back_populates="order_items")
@property
def subtotal(self):
"""Calculate the subtotal for this order item."""
return self.quantity * self.unit_price

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

@ -0,0 +1,19 @@
from sqlalchemy import Column, String, Numeric, Boolean, Text, Integer
from sqlalchemy.orm import relationship
from app.models.base import BaseModel, TimestampMixin
class Product(BaseModel, TimestampMixin):
"""Product model for items that can be purchased."""
name = Column(String, index=True, nullable=False)
description = Column(Text)
price = Column(Numeric(10, 2), nullable=False) # Supports prices up to 99,999,999.99
sku = Column(String, unique=True, index=True, nullable=False)
stock_quantity = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, default=True)
image_url = Column(String)
# Relationships
cart_items = relationship("CartItem", back_populates="product")
order_items = relationship("OrderItem", back_populates="product")

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

@ -0,0 +1,16 @@
from sqlalchemy import Column, String, Boolean
from sqlalchemy.orm import relationship
from app.models.base import BaseModel, TimestampMixin
class User(BaseModel, TimestampMixin):
"""User model for authentication and ownership of carts/orders."""
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String, index=True)
is_active = Column(Boolean, default=True)
# Relationships
carts = relationship("Cart", back_populates="user", cascade="all, delete-orphan")
orders = relationship("Order", back_populates="user", cascade="all, delete-orphan")

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

@ -0,0 +1,19 @@
# Import all schemas to make them available throughout the app
from app.schemas.base import BaseSchema, TimestampSchema # noqa: F401
from app.schemas.user import ( # noqa: F401
User, UserCreate, UserUpdate, UserInDB, UserInDBBase
)
from app.schemas.product import ( # noqa: F401
Product, ProductCreate, ProductUpdate, ProductInDB
)
from app.schemas.cart import ( # noqa: F401
Cart, CartCreate, CartUpdate, CartInDB,
CartItemCreate, CartItemUpdate, CartItemInDB, CartItemWithProduct,
AddToCartResponse
)
from app.schemas.order import ( # noqa: F401
Order, OrderCreate, OrderUpdate, OrderInDB,
OrderItemCreate, OrderItemInDB, OrderItemWithProduct,
OrderResponse
)
from app.schemas.auth import Token, TokenPayload, Login # noqa: F401

19
app/schemas/auth.py Normal file
View File

@ -0,0 +1,19 @@
from typing import Optional
from pydantic import BaseModel, EmailStr
class Token(BaseModel):
"""Schema for token response."""
access_token: str
token_type: str
class TokenPayload(BaseModel):
"""Schema for token payload (JWT claims)."""
sub: Optional[int] = None
class Login(BaseModel):
"""Schema for user login."""
email: EmailStr
password: str

16
app/schemas/base.py Normal file
View File

@ -0,0 +1,16 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class BaseSchema(BaseModel):
"""Base Pydantic schema with common configurations."""
model_config = ConfigDict(
from_attributes=True, # Allow ORM model -> Pydantic model
populate_by_name=True # Allow populating by attribute name
)
class TimestampSchema(BaseSchema):
"""Mixin for schemas that include timestamp fields."""
created_at: datetime
updated_at: datetime

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

@ -0,0 +1,125 @@
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
from pydantic import Field
from app.schemas.base import BaseSchema, TimestampSchema
from app.schemas.product import Product
class CartItemBase(BaseSchema):
"""Base schema for CartItem data."""
product_id: int
quantity: int = Field(..., gt=0)
unit_price: Decimal = Field(..., ge=0, decimal_places=2)
class CartItemCreate(BaseSchema):
"""Schema for adding an item to cart."""
product_id: int
quantity: int = Field(..., gt=0)
class CartItemUpdate(BaseSchema):
"""Schema for updating a cart item."""
quantity: int = Field(..., gt=0)
class CartItemInDBBase(CartItemBase, TimestampSchema):
"""Base schema for CartItem in DB (with ID)."""
id: int
cart_id: int
class CartItemInDB(CartItemInDBBase):
"""Schema for CartItem in DB."""
pass
class CartItemWithProduct(CartItemBase, TimestampSchema):
"""Schema for CartItem with Product details."""
id: int
product: Product
@property
def subtotal(self) -> Decimal:
"""Calculate the subtotal for this cart item."""
return self.unit_price * self.quantity
class CartBase(BaseSchema):
"""Base schema for Cart data."""
user_id: Optional[int] = None
session_id: Optional[str] = None
is_active: bool = True
expires_at: Optional[datetime] = None
class CartCreate(CartBase):
"""Schema for creating a new cart."""
pass
class CartUpdate(BaseSchema):
"""Schema for updating a cart."""
is_active: Optional[bool] = None
expires_at: Optional[datetime] = None
class CartInDBBase(CartBase, TimestampSchema):
"""Base schema for Cart in DB (with ID)."""
id: int
class CartInDB(CartInDBBase):
"""Schema for Cart in DB."""
pass
class Cart(CartInDBBase):
"""Schema for Cart response with items."""
items: List[CartItemWithProduct] = []
total_items: int = 0
total_price: Decimal = Decimal('0.00')
model_config = {
"json_schema_extra": {
"examples": [
{
"id": 1,
"user_id": 1,
"session_id": None,
"is_active": True,
"expires_at": "2023-07-31T00:00:00",
"created_at": "2023-07-24T12:00:00",
"updated_at": "2023-07-24T12:00:00",
"items": [
{
"id": 1,
"product_id": 1,
"quantity": 2,
"unit_price": "19.99",
"product": {
"id": 1,
"name": "Product 1",
"description": "Description of product 1",
"price": "19.99",
"sku": "PROD1",
"stock_quantity": 100,
"is_active": True
}
}
],
"total_items": 2,
"total_price": "39.98"
}
]
}
}
class AddToCartResponse(BaseSchema):
"""Schema for response after adding an item to cart."""
cart_id: int
message: str
cart_item: CartItemWithProduct

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

@ -0,0 +1,131 @@
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
from pydantic import Field
from app.models.order import OrderStatus, PaymentMethod
from app.schemas.base import BaseSchema, TimestampSchema
from app.schemas.product import Product
class OrderItemBase(BaseSchema):
"""Base schema for OrderItem data."""
product_id: int
quantity: int = Field(..., gt=0)
unit_price: Decimal = Field(..., ge=0, decimal_places=2)
class OrderItemCreate(OrderItemBase):
"""Schema for creating a new order item."""
pass
class OrderItemInDBBase(OrderItemBase, TimestampSchema):
"""Base schema for OrderItem in DB (with ID)."""
id: int
order_id: int
class OrderItemInDB(OrderItemInDBBase):
"""Schema for OrderItem in DB."""
pass
class OrderItemWithProduct(OrderItemInDBBase):
"""Schema for OrderItem with Product details."""
product: Product
@property
def subtotal(self) -> Decimal:
"""Calculate the subtotal for this order item."""
return self.unit_price * self.quantity
class OrderBase(BaseSchema):
"""Base schema for Order data."""
user_id: int
status: OrderStatus = OrderStatus.PENDING
total_amount: Decimal = Field(..., ge=0, decimal_places=2)
shipping_address: str
payment_method: PaymentMethod
notes: Optional[str] = None
class OrderCreate(BaseSchema):
"""Schema for creating a new order."""
cart_id: int
shipping_address: str
payment_method: PaymentMethod
notes: Optional[str] = None
class OrderUpdate(BaseSchema):
"""Schema for updating an order."""
status: Optional[OrderStatus] = None
tracking_number: Optional[str] = None
notes: Optional[str] = None
class OrderInDBBase(OrderBase, TimestampSchema):
"""Base schema for Order in DB (with ID)."""
id: int
tracking_number: Optional[str] = None
payment_id: Optional[str] = None
paid_at: Optional[datetime] = None
class OrderInDB(OrderInDBBase):
"""Schema for Order in DB."""
pass
class Order(OrderInDBBase):
"""Schema for Order response with items."""
items: List[OrderItemWithProduct] = []
model_config = {
"json_schema_extra": {
"examples": [
{
"id": 1,
"user_id": 1,
"status": "pending",
"total_amount": "39.98",
"shipping_address": "123 Main St, City, Country",
"tracking_number": None,
"payment_method": "credit_card",
"payment_id": None,
"paid_at": None,
"notes": "Please deliver during business hours",
"created_at": "2023-07-24T12:00:00",
"updated_at": "2023-07-24T12:00:00",
"items": [
{
"id": 1,
"order_id": 1,
"product_id": 1,
"quantity": 2,
"unit_price": "19.99",
"product": {
"id": 1,
"name": "Product 1",
"description": "Description of product 1",
"price": "19.99",
"sku": "PROD1",
"stock_quantity": 100,
"is_active": True
}
}
]
}
]
}
}
class OrderResponse(BaseSchema):
"""Schema for response after creating an order."""
order_id: int
message: str
status: str
total_amount: Decimal

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

@ -0,0 +1,47 @@
from typing import Optional
from decimal import Decimal
from pydantic import Field, HttpUrl
from app.schemas.base import BaseSchema, TimestampSchema
class ProductBase(BaseSchema):
"""Base schema for Product data."""
name: str
description: Optional[str] = None
price: Decimal = Field(..., ge=0, decimal_places=2)
sku: str
stock_quantity: int = Field(..., ge=0)
is_active: bool = True
image_url: Optional[HttpUrl] = None
class ProductCreate(ProductBase):
"""Schema for creating a new product."""
pass
class ProductUpdate(BaseSchema):
"""Schema for updating a product."""
name: Optional[str] = None
description: Optional[str] = None
price: Optional[Decimal] = Field(None, ge=0, decimal_places=2)
sku: Optional[str] = None
stock_quantity: Optional[int] = Field(None, ge=0)
is_active: Optional[bool] = None
image_url: Optional[HttpUrl] = None
class ProductInDBBase(ProductBase, TimestampSchema):
"""Base schema for Product in DB (with ID)."""
id: int
class Product(ProductInDBBase):
"""Schema for Product response."""
pass
class ProductInDB(ProductInDBBase):
"""Schema for Product in DB."""
pass

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

@ -0,0 +1,39 @@
from typing import Optional
from pydantic import EmailStr, Field
from app.schemas.base import BaseSchema, TimestampSchema
class UserBase(BaseSchema):
"""Base schema for User data."""
email: EmailStr
full_name: Optional[str] = None
is_active: bool = True
class UserCreate(UserBase):
"""Schema for creating a new user."""
password: str = Field(..., min_length=8)
class UserUpdate(BaseSchema):
"""Schema for updating a user."""
email: Optional[EmailStr] = None
full_name: Optional[str] = None
password: Optional[str] = Field(None, min_length=8)
is_active: Optional[bool] = None
class UserInDBBase(UserBase, TimestampSchema):
"""Base schema for User in DB (with ID)."""
id: int
class User(UserInDBBase):
"""Schema for User response."""
pass
class UserInDB(UserInDBBase):
"""Schema for User in DB (with hashed_password)."""
hashed_password: str

30
main.py Normal file
View File

@ -0,0 +1,30 @@
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.api.api import api_router
app = FastAPI(
title=settings.PROJECT_NAME,
description="Shopping Cart and Checkout API",
version="0.1.0",
openapi_url=f"{settings.API_V1_STR}/openapi.json",
docs_url="/docs",
redoc_url="/redoc",
)
# Set up CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API router
app.include_router(api_router, prefix=settings.API_V1_STR)
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

1
migrations/README Normal file
View File

@ -0,0 +1 @@
Generic single-database configuration with SQLite.

0
migrations/__init__.py Normal file
View File

84
migrations/env.py Normal file
View File

@ -0,0 +1,84 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Import database models to make them available for Alembic
from app.db.session import Base
# 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:
# Check if we're using SQLite
is_sqlite = connection.dialect.name == 'sqlite'
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=is_sqlite, # Enable batch mode 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,139 @@
"""Initial database schema
Revision ID: 001
Revises:
Create Date: 2023-07-25
"""
from alembic import op
import sqlalchemy as sa
from datetime import datetime
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create enum types
order_status = sa.Enum('pending', 'paid', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded', name='orderstatus')
payment_method = sa.Enum('credit_card', 'debit_card', 'paypal', 'bank_transfer', 'cash_on_delivery', name='paymentmethod')
# Create User table
op.create_table(
'user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', 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, default=True),
sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow),
sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
op.create_index(op.f('ix_user_full_name'), 'user', ['full_name'], unique=False)
op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False)
# Create Product table
op.create_table(
'product',
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.Numeric(precision=10, scale=2), nullable=False),
sa.Column('sku', sa.String(), nullable=False),
sa.Column('stock_quantity', sa.Integer(), nullable=False, default=0),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('image_url', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow),
sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_product_id'), 'product', ['id'], unique=False)
op.create_index(op.f('ix_product_name'), 'product', ['name'], unique=False)
op.create_index(op.f('ix_product_sku'), 'product', ['sku'], unique=True)
# Create Cart table
op.create_table(
'cart',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('session_id', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True, default=True),
sa.Column('expires_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow),
sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_cart_id'), 'cart', ['id'], unique=False)
op.create_index(op.f('ix_cart_session_id'), 'cart', ['session_id'], unique=False)
# Create Order table
op.create_table(
'order',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('status', order_status, nullable=False, default='pending'),
sa.Column('total_amount', sa.Float(), nullable=False),
sa.Column('shipping_address', sa.Text(), nullable=False),
sa.Column('tracking_number', sa.String(), nullable=True),
sa.Column('payment_method', payment_method, nullable=False),
sa.Column('payment_id', sa.String(), nullable=True),
sa.Column('paid_at', sa.DateTime(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow),
sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_order_id'), 'order', ['id'], unique=False)
# Create CartItem table
op.create_table(
'cartitem',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('cart_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False, default=1),
sa.Column('unit_price', sa.Float(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, default=datetime.utcnow),
sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow),
sa.ForeignKeyConstraint(['cart_id'], ['cart.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_cartitem_id'), 'cartitem', ['id'], unique=False)
# Create OrderItem table
op.create_table(
'orderitem',
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(), nullable=False, default=datetime.utcnow),
sa.Column('updated_at', sa.DateTime(), nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow),
sa.ForeignKeyConstraint(['order_id'], ['order.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_orderitem_id'), 'orderitem', ['id'], unique=False)
def downgrade() -> None:
# Drop tables in reverse order of creation
op.drop_table('orderitem')
op.drop_table('cartitem')
op.drop_table('order')
op.drop_table('cart')
op.drop_table('product')
op.drop_table('user')
# Drop enum types
sa.Enum(name='orderstatus').drop(op.get_bind(), checkfirst=False)
sa.Enum(name='paymentmethod').drop(op.get_bind(), checkfirst=False)

13
requirements.txt Normal file
View File

@ -0,0 +1,13 @@
fastapi>=0.95.0
uvicorn>=0.21.1
sqlalchemy>=2.0.0
pydantic>=2.0.0
alembic>=1.10.0
python-dotenv>=1.0.0
python-multipart>=0.0.6
email-validator>=2.0.0
passlib[bcrypt]>=1.7.4
python-jose[cryptography]>=3.3.0
ruff>=0.0.270
httpx>=0.24.1
pytest>=7.3.1