From d2b80dacc47ecb73ce6c8075fcf0e0260f3bd31e Mon Sep 17 00:00:00 2001 From: Automated Action Date: Sun, 18 May 2025 00:00:02 +0000 Subject: [PATCH] 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 --- README.md | 141 +++++++++- alembic.ini | 106 ++++++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/api.py | 11 + app/api/endpoints/__init__.py | 0 app/api/endpoints/cart.py | 249 ++++++++++++++++++ app/api/endpoints/checkout.py | 177 +++++++++++++ app/api/endpoints/health.py | 25 ++ app/api/endpoints/products.py | 119 +++++++++ app/core/__init__.py | 0 app/core/config.py | 34 +++ app/crud/__init__.py | 4 + app/crud/base.py | 85 ++++++ app/crud/cart.py | 122 +++++++++ app/crud/order.py | 105 ++++++++ app/crud/product.py | 42 +++ app/db/__init__.py | 0 app/db/session.py | 35 +++ app/models/__init__.py | 6 + app/models/base.py | 28 ++ app/models/cart.py | 48 ++++ app/models/order.py | 66 +++++ app/models/product.py | 19 ++ app/models/user.py | 16 ++ app/schemas/__init__.py | 19 ++ app/schemas/auth.py | 19 ++ app/schemas/base.py | 16 ++ app/schemas/cart.py | 125 +++++++++ app/schemas/order.py | 131 +++++++++ app/schemas/product.py | 47 ++++ app/schemas/user.py | 39 +++ main.py | 30 +++ migrations/README | 1 + migrations/__init__.py | 0 migrations/env.py | 84 ++++++ migrations/script.py.mako | 24 ++ .../versions/001_initial_database_schema.py | 139 ++++++++++ requirements.txt | 13 + 39 files changed, 2123 insertions(+), 2 deletions(-) create mode 100644 alembic.ini create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/api.py create mode 100644 app/api/endpoints/__init__.py create mode 100644 app/api/endpoints/cart.py create mode 100644 app/api/endpoints/checkout.py create mode 100644 app/api/endpoints/health.py create mode 100644 app/api/endpoints/products.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/crud/__init__.py create mode 100644 app/crud/base.py create mode 100644 app/crud/cart.py create mode 100644 app/crud/order.py create mode 100644 app/crud/product.py create mode 100644 app/db/__init__.py create mode 100644 app/db/session.py create mode 100644 app/models/__init__.py create mode 100644 app/models/base.py create mode 100644 app/models/cart.py create mode 100644 app/models/order.py create mode 100644 app/models/product.py create mode 100644 app/models/user.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/auth.py create mode 100644 app/schemas/base.py create mode 100644 app/schemas/cart.py create mode 100644 app/schemas/order.py create mode 100644 app/schemas/product.py create mode 100644 app/schemas/user.py create mode 100644 main.py create mode 100644 migrations/README create mode 100644 migrations/__init__.py create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/001_initial_database_schema.py create mode 100644 requirements.txt diff --git a/README.md b/README.md index e8acfba..750beb2 100644 --- a/README.md +++ b/README.md @@ -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) \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..25a3dac --- /dev/null +++ b/alembic.ini @@ -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 \ No newline at end of file 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/api.py b/app/api/api.py new file mode 100644 index 0000000..7434595 --- /dev/null +++ b/app/api/api.py @@ -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"]) \ No newline at end of file diff --git a/app/api/endpoints/__init__.py b/app/api/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/endpoints/cart.py b/app/api/endpoints/cart.py new file mode 100644 index 0000000..9976d65 --- /dev/null +++ b/app/api/endpoints/cart.py @@ -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 \ No newline at end of file diff --git a/app/api/endpoints/checkout.py b/app/api/endpoints/checkout.py new file mode 100644 index 0000000..9f077b1 --- /dev/null +++ b/app/api/endpoints/checkout.py @@ -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 + ) \ No newline at end of file diff --git a/app/api/endpoints/health.py b/app/api/endpoints/health.py new file mode 100644 index 0000000..e31fc50 --- /dev/null +++ b/app/api/endpoints/health.py @@ -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 + } \ No newline at end of file diff --git a/app/api/endpoints/products.py b/app/api/endpoints/products.py new file mode 100644 index 0000000..8442e76 --- /dev/null +++ b/app/api/endpoints/products.py @@ -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 \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..1502678 --- /dev/null +++ b/app/core/config.py @@ -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) \ No newline at end of file diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..4a69017 --- /dev/null +++ b/app/crud/__init__.py @@ -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 \ No newline at end of file diff --git a/app/crud/base.py b/app/crud/base.py new file mode 100644 index 0000000..6bf0b4d --- /dev/null +++ b/app/crud/base.py @@ -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 \ No newline at end of file diff --git a/app/crud/cart.py b/app/crud/cart.py new file mode 100644 index 0000000..75bffd0 --- /dev/null +++ b/app/crud/cart.py @@ -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) \ No newline at end of file diff --git a/app/crud/order.py b/app/crud/order.py new file mode 100644 index 0000000..02e99dd --- /dev/null +++ b/app/crud/order.py @@ -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) \ No newline at end of file diff --git a/app/crud/product.py b/app/crud/product.py new file mode 100644 index 0000000..8fd1f18 --- /dev/null +++ b/app/crud/product.py @@ -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) \ No newline at end of file diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..b17c9e5 --- /dev/null +++ b/app/db/session.py @@ -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() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..751ff4a --- /dev/null +++ b/app/models/__init__.py @@ -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 \ No newline at end of file diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..9d1b7e9 --- /dev/null +++ b/app/models/base.py @@ -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() \ No newline at end of file diff --git a/app/models/cart.py b/app/models/cart.py new file mode 100644 index 0000000..a9a2033 --- /dev/null +++ b/app/models/cart.py @@ -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 \ No newline at end of file diff --git a/app/models/order.py b/app/models/order.py new file mode 100644 index 0000000..cb6d8aa --- /dev/null +++ b/app/models/order.py @@ -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 \ No newline at end of file diff --git a/app/models/product.py b/app/models/product.py new file mode 100644 index 0000000..c14755b --- /dev/null +++ b/app/models/product.py @@ -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") \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..5467c8d --- /dev/null +++ b/app/models/user.py @@ -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") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..60e8b28 --- /dev/null +++ b/app/schemas/__init__.py @@ -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 \ No newline at end of file diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..6143652 --- /dev/null +++ b/app/schemas/auth.py @@ -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 \ No newline at end of file diff --git a/app/schemas/base.py b/app/schemas/base.py new file mode 100644 index 0000000..57af1dc --- /dev/null +++ b/app/schemas/base.py @@ -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 \ No newline at end of file diff --git a/app/schemas/cart.py b/app/schemas/cart.py new file mode 100644 index 0000000..a4c0f08 --- /dev/null +++ b/app/schemas/cart.py @@ -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 \ No newline at end of file diff --git a/app/schemas/order.py b/app/schemas/order.py new file mode 100644 index 0000000..5bb0f19 --- /dev/null +++ b/app/schemas/order.py @@ -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 \ No newline at end of file diff --git a/app/schemas/product.py b/app/schemas/product.py new file mode 100644 index 0000000..5c2a862 --- /dev/null +++ b/app/schemas/product.py @@ -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 \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..361345c --- /dev/null +++ b/app/schemas/user.py @@ -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 \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..bfbadbc --- /dev/null +++ b/main.py @@ -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) \ No newline at end of file diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..3542e0e --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration with SQLite. \ No newline at end of file diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4b30675 --- /dev/null +++ b/migrations/env.py @@ -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() \ No newline at end of file diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..37d0cac --- /dev/null +++ b/migrations/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/migrations/versions/001_initial_database_schema.py b/migrations/versions/001_initial_database_schema.py new file mode 100644 index 0000000..6f141c0 --- /dev/null +++ b/migrations/versions/001_initial_database_schema.py @@ -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) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d8d3d4a --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file