Implement simple cart system with FastAPI and SQLite

- Set up project structure with FastAPI and SQLite
- Create models for products and cart items
- Implement schemas for API request/response validation
- Add database migrations with Alembic
- Create service layer for business logic
- Implement API endpoints for cart operations
- Add health endpoint for monitoring
- Update documentation
- Fix linting issues
This commit is contained in:
Automated Action 2025-05-18 23:36:40 +00:00
parent c3b350da05
commit a28e115e4f
30 changed files with 1350 additions and 2 deletions

112
README.md
View File

@ -1,3 +1,111 @@
# FastAPI Application
# Simple Cart System
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform.
A simple shopping cart system API built with FastAPI and SQLite.
## Features
- Product management (CRUD operations)
- Shopping cart functionality
- User-specific carts
- Stock management
## Getting Started
### Prerequisites
- Python 3.8 or higher
- pip (Python package manager)
### Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd simplecartsystem
```
2. Install the dependencies:
```bash
pip install -r requirements.txt
```
3. Run database migrations:
```bash
alembic upgrade head
```
4. Start the application:
```bash
uvicorn main:app --reload
```
The API will be available at http://localhost:8000.
## API Documentation
Interactive API documentation is available at:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## API Endpoints
### Health Check
- `GET /api/v1/health` - Check API and database health
### Products
- `GET /api/v1/products` - List all products (with pagination)
- `GET /api/v1/products/{product_id}` - Get a specific product
- `POST /api/v1/products` - Create a new product
- `PUT /api/v1/products/{product_id}` - Update a product
- `DELETE /api/v1/products/{product_id}` - Delete a product
### Cart
- `GET /api/v1/cart` - View current cart
- `POST /api/v1/cart/items` - Add item to cart
- `PUT /api/v1/cart/items/{item_id}` - Update cart item quantity
- `DELETE /api/v1/cart/items/{item_id}` - Remove item from cart
- `DELETE /api/v1/cart` - Clear cart
## Cart Usage Example
### Add Product to Cart
```bash
curl -X POST "http://localhost:8000/api/v1/cart/items" \
-H "Content-Type: application/json" \
-H "user-id: user123" \
-d '{
"product_id": 1,
"quantity": 2
}'
```
### View Cart
```bash
curl -X GET "http://localhost:8000/api/v1/cart" \
-H "user-id: user123"
```
## Database Schema
The system uses SQLite with the following main tables:
- `products`: Stores product information
- `carts`: Stores user cart information
- `cart_items`: Stores items in user carts
## Technology Stack
- [FastAPI](https://fastapi.tiangolo.com/) - Web framework
- [SQLAlchemy](https://www.sqlalchemy.org/) - ORM
- [Alembic](https://alembic.sqlalchemy.org/) - Database migrations
- [Pydantic](https://pydantic-docs.helpmanual.io/) - Data validation
- [SQLite](https://www.sqlite.org/) - Database

110
alembic.ini Normal file
View File

@ -0,0 +1,110 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# 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 alembic/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:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# 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

11
alembic/README Normal file
View File

@ -0,0 +1,11 @@
Generic single-database configuration with SQLite.
To run the migrations:
```
alembic upgrade head
```
To generate a new migration (after modifying models):
```
alembic revision --autogenerate -m "description of changes"
```

83
alembic/env.py Normal file
View File

@ -0,0 +1,83 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from app.models.base_model import Base
# Import all models that should be included in migrations
from app.models.product import Product # noqa
from app.models.cart import Cart, CartItem # noqa
# This is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
is_sqlite = connection.dialect.name == "sqlite"
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=is_sqlite, # Key configuration for SQLite
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

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

View File

@ -0,0 +1,78 @@
"""initial migration
Revision ID: 00001
Revises:
Create Date: 2023-11-15
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '00001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create products table
op.create_table(
'products',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('price', sa.Float(), nullable=False),
sa.Column('stock', sa.Integer(), nullable=False),
sa.Column('image_url', sa.String(length=512), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
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 carts table
op.create_table(
'carts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('user_id', sa.String(length=255), nullable=False),
sa.Column('is_active', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_carts_id'), 'carts', ['id'], unique=False)
op.create_index(op.f('ix_carts_user_id'), 'carts', ['user_id'], unique=False)
# Create cart_items table
op.create_table(
'cart_items',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
sa.Column('cart_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.ForeignKeyConstraint(['cart_id'], ['carts.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('cart_id', 'product_id', name='uix_cart_product')
)
op.create_index(op.f('ix_cart_items_id'), 'cart_items', ['id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_cart_items_id'), table_name='cart_items')
op.drop_table('cart_items')
op.drop_index(op.f('ix_carts_user_id'), table_name='carts')
op.drop_index(op.f('ix_carts_id'), table_name='carts')
op.drop_table('carts')
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')

0
app/__init__.py Normal file
View File

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

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

109
app/api/v1/cart.py Normal file
View File

@ -0,0 +1,109 @@
from fastapi import APIRouter, Depends, HTTPException, Header, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.cart import CartItemCreate, CartItemUpdate, CartDetail, CartItemResponse
from app.schemas.common import DataResponse, ResponseBase
from app.services import cart_service
router = APIRouter(prefix="/cart", tags=["cart"])
@router.get("", response_model=DataResponse[CartDetail])
def get_cart(
user_id: str = Header(..., description="User ID for cart identification"),
db: Session = Depends(get_db)
):
"""
Get the current user's cart with all items.
"""
cart = cart_service.get_active_cart(db, user_id)
cart_detail = cart_service.get_cart_detail(db, cart.id)
return {
"success": True,
"message": "Cart retrieved successfully",
"data": cart_detail
}
@router.post("/items", response_model=DataResponse[CartItemResponse], status_code=status.HTTP_201_CREATED)
def add_to_cart(
item_data: CartItemCreate,
user_id: str = Header(..., description="User ID for cart identification"),
db: Session = Depends(get_db)
):
"""
Add a product to the user's cart.
"""
cart_item, is_new = cart_service.add_item_to_cart(db, user_id, item_data)
status_message = "Item added to cart successfully" if is_new else "Item quantity updated successfully"
return {
"success": True,
"message": status_message,
"data": cart_item
}
@router.put("/items/{item_id}", response_model=DataResponse[CartItemResponse])
def update_cart_item(
item_id: int,
item_data: CartItemUpdate,
user_id: str = Header(..., description="User ID for cart identification"),
db: Session = Depends(get_db)
):
"""
Update the quantity of an item in the user's cart.
"""
cart_item = cart_service.update_cart_item(db, user_id, item_id, item_data)
if not cart_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Cart item with ID {item_id} not found in user's cart"
)
return {
"success": True,
"message": "Cart item updated successfully",
"data": cart_item
}
@router.delete("/items/{item_id}", response_model=ResponseBase)
def remove_cart_item(
item_id: int,
user_id: str = Header(..., description="User ID for cart identification"),
db: Session = Depends(get_db)
):
"""
Remove an item from the user's cart.
"""
success = cart_service.remove_cart_item(db, user_id, item_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Cart item with ID {item_id} not found in user's cart"
)
return {
"success": True,
"message": "Item removed from cart successfully"
}
@router.delete("", response_model=ResponseBase)
def clear_cart(
user_id: str = Header(..., description="User ID for cart identification"),
db: Session = Depends(get_db)
):
"""
Remove all items from the user's cart.
"""
cart_service.clear_cart(db, user_id)
return {
"success": True,
"message": "Cart cleared successfully"
}

25
app/api/v1/health.py Normal file
View File

@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_db
router = APIRouter(prefix="/health", tags=["health"])
@router.get("")
def health_check(db: Session = Depends(get_db)):
"""
Health check endpoint to verify the API is running correctly.
"""
# Check database connection
try:
# Execute a simple query to check the database connection
db.execute("SELECT 1")
db_status = "healthy"
except Exception:
db_status = "unhealthy"
return {
"status": "ok",
"database": db_status,
}

105
app/api/v1/products.py Normal file
View File

@ -0,0 +1,105 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.product import Product, ProductCreate, ProductUpdate
from app.schemas.common import PaginatedResponse, DataResponse
from app.services import product_service
router = APIRouter(prefix="/products", tags=["products"])
@router.get("", response_model=PaginatedResponse[Product])
def get_products(
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=100),
is_active: Optional[bool] = None,
db: Session = Depends(get_db)
):
"""
Get a list of products with pagination and optional filtering.
"""
products = product_service.get_products(db, skip=skip, limit=limit, is_active=is_active)
total = product_service.get_product_count(db, is_active=is_active)
return {
"success": True,
"message": "Products retrieved successfully",
"data": products,
"total": total,
"page": skip // limit + 1 if limit > 0 else 1,
"size": limit,
"pages": (total + limit - 1) // limit if limit > 0 else 1
}
@router.get("/{product_id}", response_model=DataResponse[Product])
def get_product(product_id: int, db: Session = Depends(get_db)):
"""
Get a specific product by ID.
"""
product = product_service.get_product_by_id(db, product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {product_id} not found"
)
return {
"success": True,
"message": "Product retrieved successfully",
"data": product
}
@router.post("", response_model=DataResponse[Product], status_code=status.HTTP_201_CREATED)
def create_product(product_data: ProductCreate, db: Session = Depends(get_db)):
"""
Create a new product.
"""
product = product_service.create_product(db, product_data)
return {
"success": True,
"message": "Product created successfully",
"data": product
}
@router.put("/{product_id}", response_model=DataResponse[Product])
def update_product(
product_id: int,
product_data: ProductUpdate,
db: Session = Depends(get_db)
):
"""
Update an existing product.
"""
product = product_service.update_product(db, product_id, product_data)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {product_id} not found"
)
return {
"success": True,
"message": "Product updated successfully",
"data": product
}
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_product(product_id: int, db: Session = Depends(get_db)):
"""
Delete a product.
"""
success = product_service.delete_product(db, product_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {product_id} not found"
)
return None

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

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

@ -0,0 +1,27 @@
from typing import List, Union
from pydantic import AnyHttpUrl, validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
API_V1_STR: str = "/api/v1"
PROJECT_NAME: str = "Simple Cart System"
PROJECT_DESCRIPTION: str = "A simple cart system API built with FastAPI and SQLite"
PROJECT_VERSION: str = "0.1.0"
# CORS
BACKEND_CORS_ORIGINS: List[Union[str, AnyHttpUrl]] = ["http://localhost", "http://localhost:8000"]
@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)
class Config:
case_sensitive = True
settings = Settings()

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

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

@ -0,0 +1,24 @@
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Set up database directory
DB_DIR = Path("/app") / "storage" / "db"
DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create a dependency
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

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

@ -0,0 +1,6 @@
# Re-export models for easier imports elsewhere
from app.models.base_model import Base as Base
from app.models.product import Product as Product
from app.models.cart import Cart as Cart, CartItem as CartItem
__all__ = ["Base", "Product", "Cart", "CartItem"]

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

@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, DateTime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func
Base = declarative_base()
class BaseModel(Base):
"""Base model for all models to inherit from."""
__abstract__ = True
id = Column(Integer, primary_key=True, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)

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

@ -0,0 +1,31 @@
from sqlalchemy import Column, String, Float, Integer, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
from app.models.base_model import BaseModel
class Cart(BaseModel):
"""Model for shopping cart."""
__tablename__ = "carts"
user_id = Column(String(255), nullable=False, index=True)
is_active = Column(Integer, default=1, nullable=False)
items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan")
class CartItem(BaseModel):
"""Model for items in shopping cart."""
__tablename__ = "cart_items"
__table_args__ = (
UniqueConstraint("cart_id", "product_id", name="uix_cart_product"),
)
cart_id = Column(Integer, ForeignKey("carts.id", ondelete="CASCADE"), nullable=False)
product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
quantity = Column(Integer, nullable=False, default=1)
unit_price = Column(Float, nullable=False) # Stores the price at the time of adding to cart
cart = relationship("Cart", back_populates="items")
product = relationship("Product")

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

@ -0,0 +1,15 @@
from sqlalchemy import Column, String, Float, Integer, Text, Boolean
from app.models.base_model import BaseModel
class Product(BaseModel):
"""Model for product items."""
__tablename__ = "products"
name = Column(String(255), nullable=False, index=True)
description = Column(Text, nullable=True)
price = Column(Float, nullable=False)
stock = Column(Integer, nullable=False, default=0)
image_url = Column(String(512), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)

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

@ -0,0 +1,31 @@
# Re-export schemas for easier imports elsewhere
from app.schemas.common import (
ResponseBase as ResponseBase,
DataResponse as DataResponse,
PaginatedResponse as PaginatedResponse,
PaginationParams as PaginationParams
)
from app.schemas.product import (
Product as Product,
ProductCreate as ProductCreate,
ProductUpdate as ProductUpdate,
ProductInDB as ProductInDB
)
from app.schemas.cart import (
CartBase as CartBase,
CartCreate as CartCreate,
CartResponse as CartResponse,
CartDetail as CartDetail,
CartItemBase as CartItemBase,
CartItemCreate as CartItemCreate,
CartItemUpdate as CartItemUpdate,
CartItemResponse as CartItemResponse,
CartItemDetail as CartItemDetail
)
__all__ = [
"ResponseBase", "DataResponse", "PaginatedResponse", "PaginationParams",
"Product", "ProductCreate", "ProductUpdate", "ProductInDB",
"CartBase", "CartCreate", "CartResponse", "CartDetail",
"CartItemBase", "CartItemCreate", "CartItemUpdate", "CartItemResponse", "CartItemDetail"
]

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

@ -0,0 +1,77 @@
from typing import List
from pydantic import BaseModel, Field, validator
class CartItemBase(BaseModel):
"""Base schema for cart item data."""
product_id: int = Field(..., title="Product ID")
quantity: int = Field(..., title="Quantity", ge=1)
@validator('quantity')
def validate_quantity(cls, v):
if v < 1:
raise ValueError('Quantity must be at least 1')
return v
class CartItemCreate(CartItemBase):
"""Schema for adding an item to a cart."""
pass
class CartItemUpdate(BaseModel):
"""Schema for updating a cart item."""
quantity: int = Field(..., title="New quantity", ge=1)
@validator('quantity')
def validate_quantity(cls, v):
if v < 1:
raise ValueError('Quantity must be at least 1')
return v
class CartItemResponse(CartItemBase):
"""Schema for cart item data in API responses."""
id: int
unit_price: float
class Config:
orm_mode = True
class CartItemDetail(CartItemResponse):
"""Schema for detailed cart item data including product details."""
product_name: str
subtotal: float
class Config:
orm_mode = True
class CartBase(BaseModel):
"""Base schema for cart data."""
user_id: str = Field(..., title="User ID")
is_active: int = Field(1, title="Cart status")
class CartCreate(CartBase):
"""Schema for creating a new cart."""
pass
class CartResponse(CartBase):
"""Schema for cart data in API responses."""
id: int
created_at: str
class Config:
orm_mode = True
class CartDetail(CartResponse):
"""Schema for detailed cart data including items."""
items: List[CartItemDetail] = []
total: float
class Config:
orm_mode = True

31
app/schemas/common.py Normal file
View File

@ -0,0 +1,31 @@
from typing import Generic, TypeVar, List, Optional
from pydantic import BaseModel
T = TypeVar('T')
class PaginationParams(BaseModel):
"""Schema for pagination parameters."""
skip: int = 0
limit: int = 100
class ResponseBase(BaseModel):
"""Base schema for API responses."""
success: bool
message: str
class DataResponse(ResponseBase, Generic[T]):
"""Schema for API responses with data."""
data: Optional[T] = None
class PaginatedResponse(ResponseBase, Generic[T]):
"""Schema for paginated API responses."""
data: List[T]
total: int
page: int
size: int
pages: int

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

@ -0,0 +1,56 @@
from typing import Optional
from pydantic import BaseModel, Field, validator
class ProductBase(BaseModel):
"""Base schema for product data."""
name: str = Field(..., title="Product name", min_length=1, max_length=255)
description: Optional[str] = Field(None, title="Product description")
price: float = Field(..., title="Product price", ge=0.01)
stock: int = Field(..., title="Available quantity", ge=0)
image_url: Optional[str] = Field(None, title="Product image URL")
is_active: bool = Field(True, title="Product availability status")
@validator('price')
def validate_price(cls, v):
if v < 0:
raise ValueError('Price must be positive')
return round(v, 2)
class ProductCreate(ProductBase):
"""Schema for creating a new product."""
pass
class ProductUpdate(BaseModel):
"""Schema for updating an existing product."""
name: Optional[str] = Field(None, title="Product name", min_length=1, max_length=255)
description: Optional[str] = Field(None, title="Product description")
price: Optional[float] = Field(None, title="Product price", ge=0.01)
stock: Optional[int] = Field(None, title="Available quantity", ge=0)
image_url: Optional[str] = Field(None, title="Product image URL")
is_active: Optional[bool] = Field(None, title="Product availability status")
@validator('price')
def validate_price(cls, v):
if v is not None and v < 0:
raise ValueError('Price must be positive')
if v is not None:
return round(v, 2)
return v
class ProductInDB(ProductBase):
"""Schema for product data retrieved from the database."""
id: int
created_at: str
updated_at: str
class Config:
orm_mode = True
class Product(ProductInDB):
"""Schema for product data returned in API responses."""
pass

26
app/services/__init__.py Normal file
View File

@ -0,0 +1,26 @@
# Re-export service functions for easier imports elsewhere
from app.services.product_service import (
get_products as get_products,
get_product_by_id as get_product_by_id,
create_product as create_product,
update_product as update_product,
delete_product as delete_product,
get_product_count as get_product_count
)
from app.services.cart_service import (
get_active_cart as get_active_cart,
get_cart_with_items as get_cart_with_items,
get_cart_detail as get_cart_detail,
add_item_to_cart as add_item_to_cart,
update_cart_item as update_cart_item,
remove_cart_item as remove_cart_item,
clear_cart as clear_cart
)
__all__ = [
"get_products", "get_product_by_id", "create_product", "update_product",
"delete_product", "get_product_count", "get_active_cart", "get_cart_with_items",
"get_cart_detail", "add_item_to_cart", "update_cart_item", "remove_cart_item",
"clear_cart"
]

View File

@ -0,0 +1,213 @@
from typing import Optional, Dict, Any, Tuple
from sqlalchemy.orm import Session, joinedload
from fastapi import HTTPException, status
from app.models.cart import Cart, CartItem
from app.schemas.cart import CartItemCreate, CartItemUpdate
from app.services import product_service
def get_active_cart(db: Session, user_id: str) -> Optional[Cart]:
"""
Get the active cart for a user, or create one if it doesn't exist.
"""
cart = db.query(Cart).filter(
Cart.user_id == user_id,
Cart.is_active == 1
).first()
if not cart:
cart = Cart(user_id=user_id, is_active=1)
db.add(cart)
db.commit()
db.refresh(cart)
return cart
def get_cart_with_items(db: Session, cart_id: int) -> Optional[Cart]:
"""
Get a cart by ID with all its items.
"""
return db.query(Cart).options(
joinedload(Cart.items)
).filter(Cart.id == cart_id).first()
def get_cart_detail(db: Session, cart_id: int) -> Dict[str, Any]:
"""
Get detailed cart information including items and total.
"""
cart = get_cart_with_items(db, cart_id)
if not cart:
return None
items_with_details = []
total = 0.0
for item in cart.items:
product = product_service.get_product_by_id(db, item.product_id)
if product:
subtotal = item.quantity * item.unit_price
total += subtotal
items_with_details.append({
"id": item.id,
"product_id": item.product_id,
"product_name": product.name,
"quantity": item.quantity,
"unit_price": item.unit_price,
"subtotal": subtotal
})
return {
"id": cart.id,
"user_id": cart.user_id,
"is_active": cart.is_active,
"created_at": cart.created_at,
"items": items_with_details,
"total": round(total, 2)
}
def add_item_to_cart(
db: Session,
user_id: str,
item_data: CartItemCreate
) -> Tuple[CartItem, bool]:
"""
Add an item to a user's active cart. Returns the cart item and a boolean
indicating if it's a new item (True) or an updated existing item (False).
"""
# Get or create active cart
cart = get_active_cart(db, user_id)
# Check if product exists and is available
product = product_service.get_product_by_id(db, item_data.product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {item_data.product_id} not found"
)
if not product.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Product with ID {item_data.product_id} is not available"
)
if product.stock < item_data.quantity:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Not enough stock available. Requested: {item_data.quantity}, Available: {product.stock}"
)
# Check if the item already exists in the cart
cart_item = db.query(CartItem).filter(
CartItem.cart_id == cart.id,
CartItem.product_id == item_data.product_id
).first()
is_new = False
if cart_item:
# Update existing item
new_quantity = cart_item.quantity + item_data.quantity
if new_quantity > product.stock:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Not enough stock available. Requested total: {new_quantity}, Available: {product.stock}"
)
cart_item.quantity = new_quantity
else:
# Create new item
is_new = True
cart_item = CartItem(
cart_id=cart.id,
product_id=item_data.product_id,
quantity=item_data.quantity,
unit_price=product.price
)
db.add(cart_item)
db.commit()
db.refresh(cart_item)
return cart_item, is_new
def update_cart_item(
db: Session,
user_id: str,
item_id: int,
item_data: CartItemUpdate
) -> Optional[CartItem]:
"""
Update the quantity of an item in a user's active cart.
"""
# Get active cart
cart = get_active_cart(db, user_id)
# Find the cart item
cart_item = db.query(CartItem).filter(
CartItem.id == item_id,
CartItem.cart_id == cart.id
).first()
if not cart_item:
return None
# Check product stock
product = product_service.get_product_by_id(db, cart_item.product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {cart_item.product_id} not found"
)
if product.stock < item_data.quantity:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Not enough stock available. Requested: {item_data.quantity}, Available: {product.stock}"
)
# Update quantity
cart_item.quantity = item_data.quantity
db.commit()
db.refresh(cart_item)
return cart_item
def remove_cart_item(db: Session, user_id: str, item_id: int) -> bool:
"""
Remove an item from a user's active cart.
"""
# Get active cart
cart = get_active_cart(db, user_id)
# Find the cart item
cart_item = db.query(CartItem).filter(
CartItem.id == item_id,
CartItem.cart_id == cart.id
).first()
if not cart_item:
return False
# Remove item
db.delete(cart_item)
db.commit()
return True
def clear_cart(db: Session, user_id: str) -> bool:
"""
Remove all items from a user's active cart.
"""
# Get active cart
cart = get_active_cart(db, user_id)
# Delete all cart items
deleted = db.query(CartItem).filter(CartItem.cart_id == cart.id).delete()
db.commit()
return deleted > 0

View File

@ -0,0 +1,87 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.models.product import Product
from app.schemas.product import ProductCreate, ProductUpdate
def get_products(
db: Session,
skip: int = 0,
limit: int = 100,
is_active: Optional[bool] = None
) -> List[Product]:
"""
Get a list of products with pagination and optional filtering.
"""
query = db.query(Product)
if is_active is not None:
query = query.filter(Product.is_active == is_active)
return query.offset(skip).limit(limit).all()
def get_product_by_id(db: Session, product_id: int) -> Optional[Product]:
"""
Get a product by its ID.
"""
return db.query(Product).filter(Product.id == product_id).first()
def create_product(db: Session, product_data: ProductCreate) -> Product:
"""
Create a new product.
"""
db_product = Product(**product_data.dict())
db.add(db_product)
db.commit()
db.refresh(db_product)
return db_product
def update_product(
db: Session,
product_id: int,
product_data: ProductUpdate
) -> Optional[Product]:
"""
Update an existing product.
"""
db_product = get_product_by_id(db, product_id)
if not db_product:
return None
# Update only provided fields
update_data = product_data.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(db_product, key, value)
db.commit()
db.refresh(db_product)
return db_product
def delete_product(db: Session, product_id: int) -> bool:
"""
Delete a product by its ID.
"""
db_product = get_product_by_id(db, product_id)
if not db_product:
return False
db.delete(db_product)
db.commit()
return True
def get_product_count(db: Session, is_active: Optional[bool] = None) -> int:
"""
Get the total count of products with optional filtering.
"""
query = db.query(Product)
if is_active is not None:
query = query.filter(Product.is_active == is_active)
return query.count()

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

46
main.py Normal file
View File

@ -0,0 +1,46 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1 import health, products, cart
from app.core.config import settings
from app.db.session import engine
from app.models import base_model
# Create tables if they don't exist
base_model.Base.metadata.create_all(bind=engine)
app = FastAPI(
title=settings.PROJECT_NAME,
description=settings.PROJECT_DESCRIPTION,
version=settings.PROJECT_VERSION,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
docs_url="/docs",
redoc_url="/redoc",
)
# Set up CORS
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=["*"],
)
# Include routers
app.include_router(health.router, prefix=settings.API_V1_STR)
app.include_router(products.router, prefix=settings.API_V1_STR)
app.include_router(cart.router, prefix=settings.API_V1_STR)
@app.get("/")
async def root():
return {
"message": "Welcome to Simple Cart System API",
"documentation": "/docs",
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
fastapi>=0.104.1
uvicorn>=0.24.0
sqlalchemy>=2.0.23
alembic>=1.12.1
pydantic>=2.4.2
pydantic-settings>=2.0.3
python-multipart>=0.0.6
ruff>=0.1.5