diff --git a/README.md b/README.md index e8acfba..815c03e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,152 @@ -# FastAPI Application +# Cart and Checkout System -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A FastAPI-based REST API for managing shopping carts and processing checkouts. + +## Features + +- Create and manage shopping carts +- Add, update, and remove items from carts +- Checkout functionality with payment processing simulation +- SQLite database with SQLAlchemy ORM +- Alembic database migrations +- Automatic API documentation with FastAPI +- CORS support for cross-origin requests + +## Installation + +1. Install dependencies: +```bash +pip install -r requirements.txt +``` + +2. Run database migrations: +```bash +alembic upgrade head +``` + +3. Start the application: +```bash +uvicorn main:app --reload +``` + +The application will be available at `http://localhost:8000`. + +## API Documentation + +Once the application is running, you can access: +- Interactive API documentation: `http://localhost:8000/docs` +- Alternative documentation: `http://localhost:8000/redoc` +- OpenAPI JSON schema: `http://localhost:8000/openapi.json` + +## API Endpoints + +### Cart Management + +- `POST /api/v1/carts/` - Create a new cart +- `GET /api/v1/carts/{cart_id}` - Get cart by ID +- `GET /api/v1/carts/user/{user_id}` - Get all carts for a user +- `DELETE /api/v1/carts/{cart_id}` - Clear all items from a cart + +### Cart Items + +- `POST /api/v1/carts/{cart_id}/items/` - Add item to cart +- `PUT /api/v1/carts/{cart_id}/items/{item_id}` - Update item quantity +- `DELETE /api/v1/carts/{cart_id}/items/{item_id}` - Remove item from cart + +### Checkout + +- `POST /api/v1/checkout/` - Process checkout + +### System + +- `GET /` - Service information +- `GET /health` - Health check endpoint + +## Example Usage + +### Create a Cart +```bash +curl -X POST "http://localhost:8000/api/v1/carts/" \ + -H "Content-Type: application/json" \ + -d '{"user_id": "user123"}' +``` + +### Add Item to Cart +```bash +curl -X POST "http://localhost:8000/api/v1/carts/1/items/" \ + -H "Content-Type: application/json" \ + -d '{ + "product_id": "prod123", + "product_name": "Example Product", + "price": 29.99, + "quantity": 2 + }' +``` + +### Checkout +```bash +curl -X POST "http://localhost:8000/api/v1/checkout/" \ + -H "Content-Type: application/json" \ + -d '{ + "cart_id": 1, + "payment_method": "credit_card", + "billing_address": { + "street": "123 Main St", + "city": "Anytown", + "state": "ST", + "zip": "12345" + } + }' +``` + +## Database Schema + +### Carts Table +- `id` - Primary key +- `user_id` - User identifier +- `status` - Cart status (active, checked_out, abandoned) +- `created_at` - Creation timestamp +- `updated_at` - Last update timestamp + +### Cart Items Table +- `id` - Primary key +- `cart_id` - Foreign key to carts table +- `product_id` - Product identifier +- `product_name` - Product name +- `price` - Item price +- `quantity` - Item quantity + +## Development + +### Database Migrations + +To create a new migration: +```bash +alembic revision -m "Description of changes" +``` + +To apply migrations: +```bash +alembic upgrade head +``` + +To rollback migrations: +```bash +alembic downgrade -1 +``` + +### Code Linting + +The project uses Ruff for code formatting and linting: +```bash +ruff check . +ruff format . +``` + +## Architecture + +- **FastAPI** - Modern Python web framework +- **SQLAlchemy** - SQL toolkit and ORM +- **SQLite** - Lightweight database +- **Alembic** - Database migration tool +- **Pydantic** - Data validation and serialization \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..f23be67 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,98 @@ +# 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 +# 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 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 + +# 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 \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..84f2ca4 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,82 @@ +from logging.config import fileConfig +import sys +from pathlib import Path + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# Add the project root to the path +sys.path.append(str(Path(__file__).parent.parent)) + +# Import the Base from your models +from app.db.base 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: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..37d0cac --- /dev/null +++ b/alembic/script.py.mako @@ -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"} \ No newline at end of file diff --git a/alembic/versions/001_initial_migration.py b/alembic/versions/001_initial_migration.py new file mode 100644 index 0000000..d468679 --- /dev/null +++ b/alembic/versions/001_initial_migration.py @@ -0,0 +1,70 @@ +"""Initial migration + +Revision ID: 001 +Revises: +Create Date: 2025-07-21 10:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create carts table + op.create_table( + "carts", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.String(), nullable=True), + sa.Column("status", sa.String(), nullable=True), + 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.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("cart_id", sa.Integer(), nullable=True), + sa.Column("product_id", sa.String(), nullable=True), + sa.Column("product_name", sa.String(), nullable=True), + sa.Column("price", sa.Float(), nullable=True), + sa.Column("quantity", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["cart_id"], + ["carts.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_cart_items_id"), "cart_items", ["id"], unique=False) + op.create_index( + op.f("ix_cart_items_product_id"), "cart_items", ["product_id"], unique=False + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_cart_items_product_id"), table_name="cart_items") + 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") diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/cart.py b/app/api/cart.py new file mode 100644 index 0000000..cb065c0 --- /dev/null +++ b/app/api/cart.py @@ -0,0 +1,203 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List +import uuid + +from app.db.session import get_db +from app.models.cart import Cart, CartItem +from app.schemas import ( + CartCreate, + CartResponse, + CartItemCreate, + CartItemResponse, + CheckoutRequest, + CheckoutResponse, +) + +router = APIRouter() + + +@router.post("/carts/", response_model=CartResponse) +def create_cart(cart: CartCreate, db: Session = Depends(get_db)): + db_cart = Cart(user_id=cart.user_id) + db.add(db_cart) + db.commit() + db.refresh(db_cart) + + # Calculate total + total = sum(item.price * item.quantity for item in db_cart.items) + + response = CartResponse.model_validate(db_cart) + response.total = total + return response + + +@router.get("/carts/{cart_id}", response_model=CartResponse) +def get_cart(cart_id: int, db: Session = Depends(get_db)): + cart = db.query(Cart).filter(Cart.id == cart_id).first() + if cart is None: + raise HTTPException(status_code=404, detail="Cart not found") + + # Calculate total + total = sum(item.price * item.quantity for item in cart.items) + + response = CartResponse.model_validate(cart) + response.total = total + return response + + +@router.get("/carts/user/{user_id}", response_model=List[CartResponse]) +def get_user_carts(user_id: str, db: Session = Depends(get_db)): + carts = db.query(Cart).filter(Cart.user_id == user_id).all() + + response_carts = [] + for cart in carts: + total = sum(item.price * item.quantity for item in cart.items) + cart_response = CartResponse.model_validate(cart) + cart_response.total = total + response_carts.append(cart_response) + + return response_carts + + +@router.post("/carts/{cart_id}/items/", response_model=CartItemResponse) +def add_item_to_cart(cart_id: int, item: CartItemCreate, db: Session = Depends(get_db)): + cart = db.query(Cart).filter(Cart.id == cart_id).first() + if cart is None: + raise HTTPException(status_code=404, detail="Cart not found") + + if cart.status != "active": + raise HTTPException(status_code=400, detail="Cannot add items to inactive cart") + + # Check if item already exists in cart + existing_item = ( + db.query(CartItem) + .filter(CartItem.cart_id == cart_id, CartItem.product_id == item.product_id) + .first() + ) + + if existing_item: + # Update quantity if item exists + existing_item.quantity += item.quantity + db.commit() + db.refresh(existing_item) + return CartItemResponse.model_validate(existing_item) + else: + # Create new item if it doesn't exist + db_item = CartItem( + cart_id=cart_id, + product_id=item.product_id, + product_name=item.product_name, + price=item.price, + quantity=item.quantity, + ) + db.add(db_item) + db.commit() + db.refresh(db_item) + return CartItemResponse.model_validate(db_item) + + +@router.put("/carts/{cart_id}/items/{item_id}", response_model=CartItemResponse) +def update_cart_item( + cart_id: int, item_id: int, quantity: int, db: Session = Depends(get_db) +): + cart = db.query(Cart).filter(Cart.id == cart_id).first() + if cart is None: + raise HTTPException(status_code=404, detail="Cart not found") + + if cart.status != "active": + raise HTTPException( + status_code=400, detail="Cannot modify items in inactive cart" + ) + + item = ( + db.query(CartItem) + .filter(CartItem.id == item_id, CartItem.cart_id == cart_id) + .first() + ) + + if item is None: + raise HTTPException(status_code=404, detail="Cart item not found") + + if quantity <= 0: + db.delete(item) + db.commit() + return {"message": "Item removed from cart"} + else: + item.quantity = quantity + db.commit() + db.refresh(item) + return CartItemResponse.model_validate(item) + + +@router.delete("/carts/{cart_id}/items/{item_id}") +def remove_item_from_cart(cart_id: int, item_id: int, db: Session = Depends(get_db)): + cart = db.query(Cart).filter(Cart.id == cart_id).first() + if cart is None: + raise HTTPException(status_code=404, detail="Cart not found") + + if cart.status != "active": + raise HTTPException( + status_code=400, detail="Cannot remove items from inactive cart" + ) + + item = ( + db.query(CartItem) + .filter(CartItem.id == item_id, CartItem.cart_id == cart_id) + .first() + ) + + if item is None: + raise HTTPException(status_code=404, detail="Cart item not found") + + db.delete(item) + db.commit() + return {"message": "Item removed from cart"} + + +@router.delete("/carts/{cart_id}") +def clear_cart(cart_id: int, db: Session = Depends(get_db)): + cart = db.query(Cart).filter(Cart.id == cart_id).first() + if cart is None: + raise HTTPException(status_code=404, detail="Cart not found") + + if cart.status != "active": + raise HTTPException(status_code=400, detail="Cannot clear inactive cart") + + # Delete all items in the cart + db.query(CartItem).filter(CartItem.cart_id == cart_id).delete() + db.commit() + return {"message": "Cart cleared successfully"} + + +@router.post("/checkout/", response_model=CheckoutResponse) +def checkout(checkout_request: CheckoutRequest, db: Session = Depends(get_db)): + cart = db.query(Cart).filter(Cart.id == checkout_request.cart_id).first() + if cart is None: + raise HTTPException(status_code=404, detail="Cart not found") + + if cart.status != "active": + raise HTTPException(status_code=400, detail="Cart is not active") + + if not cart.items: + raise HTTPException(status_code=400, detail="Cart is empty") + + # Calculate total + total_amount = sum(item.price * item.quantity for item in cart.items) + + # In a real system, you would process payment here + # For now, we'll simulate a successful payment + + # Generate order ID + order_id = str(uuid.uuid4()) + + # Update cart status to checked_out + cart.status = "checked_out" + db.commit() + + return CheckoutResponse( + success=True, + order_id=order_id, + total_amount=total_amount, + message="Checkout completed successfully", + ) diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..860e542 --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,3 @@ +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..fbff7a0 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,22 @@ +from pathlib import Path +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +DB_DIR = Path("/app") / "storage" / "db" +DB_DIR.mkdir(parents=True, exist_ok=True) + +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/cart.py b/app/models/cart.py new file mode 100644 index 0000000..7273d1b --- /dev/null +++ b/app/models/cart.py @@ -0,0 +1,33 @@ +from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.db.base import Base + + +class Cart(Base): + __tablename__ = "carts" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(String, index=True) + status = Column(String, default="active") # active, checked_out, abandoned + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + items = relationship( + "CartItem", back_populates="cart", cascade="all, delete-orphan" + ) + + +class CartItem(Base): + __tablename__ = "cart_items" + + id = Column(Integer, primary_key=True, index=True) + cart_id = Column(Integer, ForeignKey("carts.id")) + product_id = Column(String, index=True) + product_name = Column(String) + price = Column(Float) + quantity = Column(Integer) + + cart = relationship("Cart", back_populates="items") diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..7f02a42 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,51 @@ +from typing import List, Optional +from pydantic import BaseModel +from datetime import datetime + + +class CartItemCreate(BaseModel): + product_id: str + product_name: str + price: float + quantity: int + + +class CartItemResponse(BaseModel): + id: int + product_id: str + product_name: str + price: float + quantity: int + + class Config: + from_attributes = True + + +class CartCreate(BaseModel): + user_id: str + + +class CartResponse(BaseModel): + id: int + user_id: Optional[str] + status: str + created_at: datetime + updated_at: datetime + items: List[CartItemResponse] = [] + total: Optional[float] = None + + class Config: + from_attributes = True + + +class CheckoutRequest(BaseModel): + cart_id: int + payment_method: str + billing_address: dict + + +class CheckoutResponse(BaseModel): + success: bool + order_id: Optional[str] = None + total_amount: float + message: str diff --git a/main.py b/main.py new file mode 100644 index 0000000..74b86d2 --- /dev/null +++ b/main.py @@ -0,0 +1,42 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.cart import router as cart_router +from app.db.session import engine +from app.db.base import Base + +# Create database tables +Base.metadata.create_all(bind=engine) + +app = FastAPI( + title="Cart and Checkout System", + description="A simple cart and checkout system API", + version="1.0.0", + openapi_url="/openapi.json", +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(cart_router, prefix="/api/v1", tags=["Cart"]) + + +@app.get("/") +async def root(): + return { + "title": "Cart and Checkout System", + "documentation": "/docs", + "health": "/health", + } + + +@app.get("/health") +async def health_check(): + return {"status": "healthy", "service": "cart-checkout-system"} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b9515ae --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +sqlalchemy==2.0.23 +alembic==1.12.1 +pydantic==2.5.0 +ruff==0.1.6 \ No newline at end of file