From 65cfb5b050ce3dbdc835cbdc8245cdd5794374c1 Mon Sep 17 00:00:00 2001 From: Automated Action Date: Wed, 4 Jun 2025 22:37:35 +0000 Subject: [PATCH] Create e-commerce API with FastAPI and SQLite --- README.md | 188 +++++++++++++++- alembic.ini | 85 ++++++++ app/__init__.py | 1 + app/api/__init__.py | 1 + app/api/deps.py | 63 ++++++ app/api/v1/__init__.py | 1 + app/api/v1/api.py | 16 ++ app/api/v1/endpoints/__init__.py | 1 + app/api/v1/endpoints/auth.py | 84 ++++++++ app/api/v1/endpoints/cart.py | 172 +++++++++++++++ app/api/v1/endpoints/categories.py | 119 ++++++++++ app/api/v1/endpoints/health.py | 29 +++ app/api/v1/endpoints/orders.py | 250 ++++++++++++++++++++++ app/api/v1/endpoints/payments.py | 81 +++++++ app/api/v1/endpoints/products.py | 143 +++++++++++++ app/api/v1/endpoints/users.py | 106 +++++++++ app/core/__init__.py | 1 + app/core/config.py | 38 ++++ app/core/security.py | 63 ++++++ app/db/__init__.py | 1 + app/db/session.py | 28 +++ app/models/__init__.py | 15 ++ app/models/base.py | 16 ++ app/models/cart.py | 19 ++ app/models/order.py | 49 +++++ app/models/product.py | 34 +++ app/models/user.py | 21 ++ app/schemas/__init__.py | 29 +++ app/schemas/cart.py | 35 +++ app/schemas/order.py | 53 +++++ app/schemas/product.py | 53 +++++ app/schemas/token.py | 12 ++ app/schemas/user.py | 39 ++++ app/services/__init__.py | 1 + app/utils/__init__.py | 1 + main.py | 35 +++ migrations/README | 1 + migrations/__init__.py | 1 + migrations/env.py | 80 +++++++ migrations/script.py.mako | 24 +++ migrations/versions/0001_create_tables.py | 114 ++++++++++ migrations/versions/__init__.py | 1 + requirements.txt | 13 ++ 43 files changed, 2115 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/deps.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/api/v1/api.py create mode 100644 app/api/v1/endpoints/__init__.py create mode 100644 app/api/v1/endpoints/auth.py create mode 100644 app/api/v1/endpoints/cart.py create mode 100644 app/api/v1/endpoints/categories.py create mode 100644 app/api/v1/endpoints/health.py create mode 100644 app/api/v1/endpoints/orders.py create mode 100644 app/api/v1/endpoints/payments.py create mode 100644 app/api/v1/endpoints/products.py create mode 100644 app/api/v1/endpoints/users.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/core/security.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/cart.py create mode 100644 app/schemas/order.py create mode 100644 app/schemas/product.py create mode 100644 app/schemas/token.py create mode 100644 app/schemas/user.py create mode 100644 app/services/__init__.py create mode 100644 app/utils/__init__.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/0001_create_tables.py create mode 100644 migrations/versions/__init__.py create mode 100644 requirements.txt diff --git a/README.md b/README.md index e8acfba..394fb8e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,187 @@ -# FastAPI Application +# E-Commerce API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A FastAPI-based RESTful API for an e-commerce application with user authentication, product catalog, shopping cart, order processing, and payment processing. + +## Features + +- User authentication with JWT +- Product catalog with categories +- Shopping cart functionality +- Order management +- Mock payment processing +- Search and filtering products +- Admin and regular user roles + +## Technology Stack + +- **Framework**: FastAPI +- **Database**: SQLite +- **ORM**: SQLAlchemy +- **Migration Tool**: Alembic +- **Authentication**: JWT with password hashing + +## Project Structure + +``` +ecommerce-api/ +├── app/ +│ ├── api/ +│ │ ├── deps.py +│ │ └── v1/ +│ │ ├── api.py +│ │ └── endpoints/ +│ │ ├── auth.py +│ │ ├── cart.py +│ │ ├── categories.py +│ │ ├── health.py +│ │ ├── orders.py +│ │ ├── payments.py +│ │ ├── products.py +│ │ └── users.py +│ ├── core/ +│ │ ├── config.py +│ │ └── security.py +│ ├── db/ +│ │ └── session.py +│ ├── models/ +│ │ ├── base.py +│ │ ├── cart.py +│ │ ├── order.py +│ │ ├── product.py +│ │ └── user.py +│ ├── schemas/ +│ │ ├── cart.py +│ │ ├── order.py +│ │ ├── product.py +│ │ ├── token.py +│ │ └── user.py +│ └── services/ +├── migrations/ +│ ├── env.py +│ ├── script.py.mako +│ └── versions/ +│ └── 0001_create_tables.py +├── storage/ +│ └── db/ +├── alembic.ini +├── main.py +└── requirements.txt +``` + +## Setup and Installation + +### 1. Clone the repository + +```bash +git clone https://github.com/your-username/ecommerce-api.git +cd ecommerce-api +``` + +### 2. Create a virtual environment + +```bash +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +### 3. Install dependencies + +```bash +pip install -r requirements.txt +``` + +### 4. Set up environment variables + +Create a `.env` file in the root directory: + +``` +JWT_SECRET_KEY=your_secret_key_here +``` + +### 5. Initialize the database + +```bash +alembic upgrade head +``` + +### 6. Run the application + +```bash +uvicorn main:app --reload +``` + +The API will be available at http://localhost:8000. + +## API Documentation + +Once the application is running, you can access the API documentation at: + +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## API Endpoints + +### Authentication + +- `POST /api/v1/auth/register` - Register a new user +- `POST /api/v1/auth/login` - Login and get access token + +### Users + +- `GET /api/v1/users/me` - Get current user information +- `PUT /api/v1/users/me` - Update current user information +- `GET /api/v1/users/{user_id}` - Get user by ID (admin only) +- `GET /api/v1/users/` - List all users (admin only) + +### Categories + +- `GET /api/v1/categories/` - List all categories +- `POST /api/v1/categories/` - Create a new category (admin only) +- `GET /api/v1/categories/{category_id}` - Get category by ID +- `PUT /api/v1/categories/{category_id}` - Update a category (admin only) +- `DELETE /api/v1/categories/{category_id}` - Delete a category (admin only) + +### Products + +- `GET /api/v1/products/` - List all products (with filtering options) +- `POST /api/v1/products/` - Create a new product (admin only) +- `GET /api/v1/products/{product_id}` - Get product by ID +- `PUT /api/v1/products/{product_id}` - Update a product (admin only) +- `DELETE /api/v1/products/{product_id}` - Delete a product (admin only) + +### Cart + +- `GET /api/v1/cart/` - Get user's cart +- `POST /api/v1/cart/items` - Add item to cart +- `PUT /api/v1/cart/items/{item_id}` - Update cart item quantity +- `DELETE /api/v1/cart/items/{item_id}` - Remove item from cart +- `DELETE /api/v1/cart/` - Clear cart + +### Orders + +- `GET /api/v1/orders/` - List user's orders (admin can see all) +- `POST /api/v1/orders/` - Create a new order from cart +- `GET /api/v1/orders/{order_id}` - Get order by ID +- `PUT /api/v1/orders/{order_id}` - Update order (limited for regular users) +- `DELETE /api/v1/orders/{order_id}` - Cancel order + +### Payments + +- `POST /api/v1/payments/` - Process payment for an order + +### Health Check + +- `GET /health` - Application health check +- `GET /api/v1/health/` - Detailed health check + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| JWT_SECRET_KEY | Secret key for JWT token generation | supersecretkey | +| JWT_ALGORITHM | Algorithm used for JWT | HS256 | +| ACCESS_TOKEN_EXPIRE_MINUTES | Token expiration time in minutes | 30 | + +## Database + +The application uses SQLite as the database. The database file is created at `/app/storage/db/db.sqlite`. \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..5a272eb --- /dev/null +++ b/alembic.ini @@ -0,0 +1,85 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat migrations/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# SQLite URL +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks=black +# black.type=console_scripts +# black.entrypoint=black +# black.options=-l 79 + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8e8bb6e --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# This file marks the app directory as a Python package \ No newline at end of file diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..b995596 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +# This file marks the api directory as a Python package \ No newline at end of file diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..b2bc0c3 --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,63 @@ + +from fastapi import Depends, HTTPException, status +from jose import jwt +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.security import oauth2_scheme +from app.db.session import get_db +from app.models.user import User +from app.schemas.token import TokenPayload + + +def get_current_user( + db: Session = Depends(get_db), token: str = Depends(oauth2_scheme) +) -> User: + """ + Get current user from token. + """ + try: + payload = jwt.decode( + token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM] + ) + token_data = TokenPayload(**payload) + except (jwt.JWTError, ValidationError): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials", + ) + + user = db.query(User).filter(User.id == token_data.sub).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + + return user + + +def get_current_active_user( + current_user: User = Depends(get_current_user), +) -> User: + """ + Get current active user. + """ + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + + +def get_current_active_admin( + current_user: User = Depends(get_current_user), +) -> User: + """ + Get current active admin user. + """ + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + if not current_user.is_admin: + raise HTTPException( + status_code=403, detail="The user doesn't have enough privileges" + ) + return current_user \ No newline at end of file diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..8e4e0c5 --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1 @@ +# This file marks the v1 directory as a Python package \ No newline at end of file diff --git a/app/api/v1/api.py b/app/api/v1/api.py new file mode 100644 index 0000000..4f37a89 --- /dev/null +++ b/app/api/v1/api.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter + +from app.api.v1.endpoints import auth, cart, categories, health, orders, payments, products, users +from app.core.config import settings + +api_router = APIRouter(prefix=settings.API_V1_STR) + +# Include routers for different modules +api_router.include_router(auth.router, prefix="/auth", tags=["authentication"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(categories.router, prefix="/categories", tags=["categories"]) +api_router.include_router(products.router, prefix="/products", tags=["products"]) +api_router.include_router(cart.router, prefix="/cart", tags=["cart"]) +api_router.include_router(orders.router, prefix="/orders", tags=["orders"]) +api_router.include_router(payments.router, prefix="/payments", tags=["payments"]) +api_router.include_router(health.router, prefix="/health", tags=["health"]) \ No newline at end of file diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..eb0127f --- /dev/null +++ b/app/api/v1/endpoints/__init__.py @@ -0,0 +1 @@ +# This file marks the endpoints directory as a Python package \ No newline at end of file diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..9244bb2 --- /dev/null +++ b/app/api/v1/endpoints/auth.py @@ -0,0 +1,84 @@ +from datetime import timedelta +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.security import create_access_token, get_password_hash, verify_password +from app.db.session import get_db +from app.models.user import User +from app.schemas.token import Token +from app.schemas.user import UserCreate + +router = APIRouter() + + +@router.post("/login", response_model=Token) +def login_access_token( + db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends() +) -> Any: + """ + OAuth2 compatible token login, get an access token for future requests. + """ + user = db.query(User).filter(User.email == form_data.username).first() + if not user or not verify_password(form_data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user" + ) + + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + return { + "access_token": create_access_token( + user.id, expires_delta=access_token_expires + ), + "token_type": "bearer", + } + + +@router.post("/register", response_model=Token) +def register_user( + *, db: Session = Depends(get_db), user_in: UserCreate +) -> Any: + """ + Register a new user and return an access token. + """ + # Check if user already exists + user = db.query(User).filter(User.email == user_in.email).first() + if user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A user with this email already exists", + ) + + # Create new user + db_user = User( + email=user_in.email, + hashed_password=get_password_hash(user_in.password), + full_name=user_in.full_name, + phone=user_in.phone, + address=user_in.address, + is_active=user_in.is_active, + is_admin=user_in.is_admin, + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + + # Create access token + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + return { + "access_token": create_access_token( + db_user.id, expires_delta=access_token_expires + ), + "token_type": "bearer", + } \ No newline at end of file diff --git a/app/api/v1/endpoints/cart.py b/app/api/v1/endpoints/cart.py new file mode 100644 index 0000000..49d4c23 --- /dev/null +++ b/app/api/v1/endpoints/cart.py @@ -0,0 +1,172 @@ +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.db.session import get_db +from app.models.cart import CartItem +from app.models.product import Product +from app.models.user import User +from app.schemas.cart import Cart, CartItem as CartItemSchema +from app.schemas.cart import CartItemCreate, CartItemUpdate + +router = APIRouter() + + +@router.get("/", response_model=Cart) +def read_cart( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> Any: + """ + Retrieve current user's cart. + """ + cart_items = db.query(CartItem).filter(CartItem.user_id == current_user.id).all() + + # Calculate total + total = 0.0 + for item in cart_items: + product = db.query(Product).filter(Product.id == item.product_id).first() + if product: + total += product.price * item.quantity + + return { + "items": cart_items, + "total": total, + } + + +@router.post("/items", response_model=CartItemSchema) +def add_to_cart( + *, + db: Session = Depends(get_db), + item_in: CartItemCreate, + current_user: User = Depends(get_current_user), +) -> Any: + """ + Add item to cart. + """ + # Check if product exists and is active + product = db.query(Product).filter(Product.id == item_in.product_id).first() + if not product or not product.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + + # Check if product has enough stock + if product.stock_quantity < item_in.quantity: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Not enough stock available", + ) + + # Check if item is already in cart + cart_item = db.query(CartItem).filter( + CartItem.user_id == current_user.id, + CartItem.product_id == item_in.product_id, + ).first() + + if cart_item: + # Update quantity + cart_item.quantity += item_in.quantity + db.add(cart_item) + db.commit() + db.refresh(cart_item) + return cart_item + + # Create new cart item + cart_item = CartItem( + user_id=current_user.id, + product_id=item_in.product_id, + quantity=item_in.quantity, + ) + db.add(cart_item) + db.commit() + db.refresh(cart_item) + return cart_item + + +@router.put("/items/{item_id}", response_model=CartItemSchema) +def update_cart_item( + *, + db: Session = Depends(get_db), + item_id: str, + item_in: CartItemUpdate, + current_user: User = Depends(get_current_user), +) -> Any: + """ + Update cart item quantity. + """ + cart_item = db.query(CartItem).filter( + CartItem.id == item_id, + CartItem.user_id == current_user.id, + ).first() + + if not cart_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cart item not found", + ) + + # Check if product has enough stock + product = db.query(Product).filter(Product.id == cart_item.product_id).first() + if not product or not product.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + + if product.stock_quantity < item_in.quantity: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Not enough stock available", + ) + + cart_item.quantity = item_in.quantity + db.add(cart_item) + db.commit() + db.refresh(cart_item) + return cart_item + + +@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: str, + current_user: User = Depends(get_current_user), +) -> Any: + """ + Remove item from cart. + """ + cart_item = db.query(CartItem).filter( + CartItem.id == item_id, + CartItem.user_id == current_user.id, + ).first() + + if not cart_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cart item not found", + ) + + db.delete(cart_item) + db.commit() + + return None + + +@router.delete("/", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +def clear_cart( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> Any: + """ + Clear cart. + """ + db.query(CartItem).filter(CartItem.user_id == current_user.id).delete() + db.commit() + + return None \ No newline at end of file diff --git a/app/api/v1/endpoints/categories.py b/app/api/v1/endpoints/categories.py new file mode 100644 index 0000000..bb2f182 --- /dev/null +++ b/app/api/v1/endpoints/categories.py @@ -0,0 +1,119 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.deps import get_current_active_admin +from app.db.session import get_db +from app.models.product import Category +from app.models.user import User +from app.schemas.product import Category as CategorySchema +from app.schemas.product import CategoryCreate, CategoryUpdate + +router = APIRouter() + + +@router.get("/", response_model=List[CategorySchema]) +def read_categories( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Retrieve categories. + """ + categories = db.query(Category).offset(skip).limit(limit).all() + return categories + + +@router.post("/", response_model=CategorySchema) +def create_category( + *, + db: Session = Depends(get_db), + category_in: CategoryCreate, + current_user: User = Depends(get_current_active_admin), +) -> Any: + """ + Create new category. Only admin users can create categories. + """ + # Check if category with the same name already exists + category = db.query(Category).filter(Category.name == category_in.name).first() + if category: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Category with this name already exists", + ) + + category = Category(**category_in.dict()) + db.add(category) + db.commit() + db.refresh(category) + return category + + +@router.get("/{category_id}", response_model=CategorySchema) +def read_category( + *, + db: Session = Depends(get_db), + category_id: str, +) -> Any: + """ + Get category by ID. + """ + category = db.query(Category).filter(Category.id == category_id).first() + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found", + ) + return category + + +@router.put("/{category_id}", response_model=CategorySchema) +def update_category( + *, + db: Session = Depends(get_db), + category_id: str, + category_in: CategoryUpdate, + current_user: User = Depends(get_current_active_admin), +) -> Any: + """ + Update a category. Only admin users can update categories. + """ + category = db.query(Category).filter(Category.id == category_id).first() + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found", + ) + + update_data = category_in.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(category, field, value) + + db.add(category) + db.commit() + db.refresh(category) + return category + + +@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +def delete_category( + *, + db: Session = Depends(get_db), + category_id: str, + current_user: User = Depends(get_current_active_admin), +) -> Any: + """ + Delete a category. Only admin users can delete categories. + """ + category = db.query(Category).filter(Category.id == category_id).first() + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found", + ) + + db.delete(category) + db.commit() + return None \ No newline at end of file diff --git a/app/api/v1/endpoints/health.py b/app/api/v1/endpoints/health.py new file mode 100644 index 0000000..01f2c0e --- /dev/null +++ b/app/api/v1/endpoints/health.py @@ -0,0 +1,29 @@ +from typing import Any, Dict + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.db.session import get_db + +router = APIRouter() + + +@router.get("/", response_model=Dict[str, Any]) +async def health_check(db: Session = Depends(get_db)) -> Any: + """ + Check API health. + """ + # Check if database connection is working + db_status = "healthy" + try: + # Try executing a simple query + db.execute("SELECT 1") + except Exception: + db_status = "unhealthy" + + return { + "status": "healthy", + "version": settings.PROJECT_VERSION, + "database": db_status, + } \ No newline at end of file diff --git a/app/api/v1/endpoints/orders.py b/app/api/v1/endpoints/orders.py new file mode 100644 index 0000000..fb7a601 --- /dev/null +++ b/app/api/v1/endpoints/orders.py @@ -0,0 +1,250 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.db.session import get_db +from app.models.cart import CartItem +from app.models.order import Order, OrderItem, OrderStatus +from app.models.product import Product +from app.models.user import User +from app.schemas.order import Order as OrderSchema +from app.schemas.order import OrderCreate, OrderUpdate + +router = APIRouter() + + +@router.get("/", response_model=List[OrderSchema]) +def read_orders( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Retrieve orders. + """ + # If user is admin, return all orders + if current_user.is_admin: + orders = db.query(Order).offset(skip).limit(limit).all() + else: + # If user is not admin, return only their orders + orders = db.query(Order).filter(Order.user_id == current_user.id).offset(skip).limit(limit).all() + + return orders + + +@router.post("/", response_model=OrderSchema) +def create_order( + *, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), + order_in: OrderCreate = None, +) -> Any: + """ + Create new order from cart. + """ + # Get cart items + cart_items = db.query(CartItem).filter(CartItem.user_id == current_user.id).all() + + if not cart_items: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cart is empty", + ) + + # Calculate total amount + total_amount = 0.0 + order_items_data = [] + + for cart_item in cart_items: + product = db.query(Product).filter(Product.id == cart_item.product_id).first() + + if not product or not product.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Product {cart_item.product_id} not found or not active", + ) + + # Check if product has enough stock + if product.stock_quantity < cart_item.quantity: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Not enough stock for product {product.name}", + ) + + # Add to total + item_total = product.price * cart_item.quantity + total_amount += item_total + + # Prepare order item data + order_items_data.append({ + "product_id": product.id, + "quantity": cart_item.quantity, + "price": product.price, + }) + + # Update product stock + product.stock_quantity -= cart_item.quantity + db.add(product) + + # Create order + order_data = { + "user_id": current_user.id, + "total_amount": total_amount, + "status": OrderStatus.PENDING, + } + + if order_in: + order_data.update({ + "shipping_address": order_in.shipping_address, + "tracking_number": order_in.tracking_number, + "payment_id": order_in.payment_id, + "status": order_in.status, + }) + + order = Order(**order_data) + db.add(order) + db.commit() + db.refresh(order) + + # Create order items + for item_data in order_items_data: + order_item = OrderItem( + order_id=order.id, + **item_data, + ) + db.add(order_item) + + db.commit() + + # Clear cart + db.query(CartItem).filter(CartItem.user_id == current_user.id).delete() + db.commit() + + # Refresh order to get items + db.refresh(order) + + return order + + +@router.get("/{order_id}", response_model=OrderSchema) +def read_order( + *, + db: Session = Depends(get_db), + order_id: str, + current_user: User = Depends(get_current_user), +) -> Any: + """ + Get order by ID. + """ + order = db.query(Order).filter(Order.id == order_id).first() + + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found", + ) + + # Check permissions + if not current_user.is_admin and order.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + return order + + +@router.put("/{order_id}", response_model=OrderSchema) +def update_order( + *, + db: Session = Depends(get_db), + order_id: str, + order_in: OrderUpdate, + current_user: User = Depends(get_current_user), +) -> Any: + """ + Update an order. Only admin users can update any order. Regular users can only update their own orders. + """ + order = db.query(Order).filter(Order.id == order_id).first() + + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found", + ) + + # Check permissions + if not current_user.is_admin and order.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + # Regular users can only update shipping address + if not current_user.is_admin: + if hasattr(order_in, "status") and order_in.status != order.status: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot update order status", + ) + + update_data = order_in.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(order, field, value) + + db.add(order) + db.commit() + db.refresh(order) + + return order + + +@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +def cancel_order( + *, + db: Session = Depends(get_db), + order_id: str, + current_user: User = Depends(get_current_user), +) -> Any: + """ + Cancel an order. Only admin users can cancel any order. Regular users can only cancel their own orders. + """ + order = db.query(Order).filter(Order.id == order_id).first() + + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found", + ) + + # Check permissions + if not current_user.is_admin and order.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + # Check if order can be cancelled + if order.status not in [OrderStatus.PENDING, OrderStatus.PAID]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot cancel order with status {order.status}", + ) + + # Restore product stock + order_items = db.query(OrderItem).filter(OrderItem.order_id == order.id).all() + for item in order_items: + product = db.query(Product).filter(Product.id == item.product_id).first() + if product: + product.stock_quantity += item.quantity + db.add(product) + + # Update order status + order.status = OrderStatus.CANCELLED + db.add(order) + db.commit() + + return None \ No newline at end of file diff --git a/app/api/v1/endpoints/payments.py b/app/api/v1/endpoints/payments.py new file mode 100644 index 0000000..f675e29 --- /dev/null +++ b/app/api/v1/endpoints/payments.py @@ -0,0 +1,81 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.db.session import get_db +from app.models.order import Order, OrderStatus +from app.models.user import User + +router = APIRouter() + + +class PaymentRequest(BaseModel): + order_id: str + payment_method: str + card_number: str + card_expiry: str + card_cvv: str + + +class PaymentResponse(BaseModel): + payment_id: str + order_id: str + amount: float + status: str + message: str + + +@router.post("/", response_model=PaymentResponse) +def process_payment( + *, + db: Session = Depends(get_db), + payment_in: PaymentRequest, + current_user: User = Depends(get_current_user), +) -> Any: + """ + Process payment for an order. This is a mock implementation. + """ + # Get order + order = db.query(Order).filter(Order.id == payment_in.order_id).first() + + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found", + ) + + # Check permissions + if order.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + # Check if order can be paid + if order.status != OrderStatus.PENDING: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot process payment for order with status {order.status}", + ) + + # Mock payment processing + # In a real application, this would integrate with a payment gateway + payment_id = str(uuid.uuid4()) + + # Update order status and payment ID + order.status = OrderStatus.PAID + order.payment_id = payment_id + db.add(order) + db.commit() + + return { + "payment_id": payment_id, + "order_id": order.id, + "amount": order.total_amount, + "status": "success", + "message": "Payment processed successfully", + } \ No newline at end of file diff --git a/app/api/v1/endpoints/products.py b/app/api/v1/endpoints/products.py new file mode 100644 index 0000000..b1b7250 --- /dev/null +++ b/app/api/v1/endpoints/products.py @@ -0,0 +1,143 @@ +from typing import Any, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.deps import get_current_active_admin +from app.db.session import get_db +from app.models.product import Product +from app.models.user import User +from app.schemas.product import Product as ProductSchema +from app.schemas.product import ProductCreate, ProductUpdate + +router = APIRouter() + + +@router.get("/", response_model=List[ProductSchema]) +def read_products( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + category_id: Optional[str] = None, + name: Optional[str] = None, + min_price: Optional[float] = None, + max_price: Optional[float] = None, +) -> Any: + """ + Retrieve products with optional filtering. + """ + query = db.query(Product) + + # Apply filters if provided + if category_id: + query = query.filter(Product.category_id == category_id) + + if name: + query = query.filter(Product.name.ilike(f"%{name}%")) + + if min_price is not None: + query = query.filter(Product.price >= min_price) + + if max_price is not None: + query = query.filter(Product.price <= max_price) + + # Only show active products + query = query.filter(Product.is_active.is_(True)) + + products = query.offset(skip).limit(limit).all() + return products + + +@router.post("/", response_model=ProductSchema) +def create_product( + *, + db: Session = Depends(get_db), + product_in: ProductCreate, + current_user: User = Depends(get_current_active_admin), +) -> Any: + """ + Create new product. Only admin users can create products. + """ + product = Product(**product_in.dict()) + db.add(product) + db.commit() + db.refresh(product) + return product + + +@router.get("/{product_id}", response_model=ProductSchema) +def read_product( + *, + db: Session = Depends(get_db), + product_id: str, +) -> Any: + """ + Get product by ID. + """ + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + + if not product.is_active: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + + return product + + +@router.put("/{product_id}", response_model=ProductSchema) +def update_product( + *, + db: Session = Depends(get_db), + product_id: str, + product_in: ProductUpdate, + current_user: User = Depends(get_current_active_admin), +) -> Any: + """ + Update a product. Only admin users can update products. + """ + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + + update_data = product_in.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(product, field, value) + + db.add(product) + db.commit() + db.refresh(product) + return product + + +@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +def delete_product( + *, + db: Session = Depends(get_db), + product_id: str, + current_user: User = Depends(get_current_active_admin), +) -> Any: + """ + Delete a product. Only admin users can delete products. + """ + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + + # Soft delete by setting is_active to False + product.is_active = False + db.add(product) + db.commit() + + return None \ No newline at end of file diff --git a/app/api/v1/endpoints/users.py b/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..b4b2596 --- /dev/null +++ b/app/api/v1/endpoints/users.py @@ -0,0 +1,106 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.core.security import get_current_user, get_password_hash +from app.db.session import get_db +from app.models.user import User +from app.schemas.user import User as UserSchema +from app.schemas.user import UserUpdate + +router = APIRouter() + + +@router.get("/me", response_model=UserSchema) +def read_user_me( + db: Session = Depends(get_db), + current_user_id: str = Depends(get_current_user), +) -> Any: + """ + Get current user. + """ + user = db.query(User).filter(User.id == current_user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + return user + + +@router.put("/me", response_model=UserSchema) +def update_user_me( + *, + db: Session = Depends(get_db), + current_user_id: str = Depends(get_current_user), + user_in: UserUpdate, +) -> Any: + """ + Update current user. + """ + user = db.query(User).filter(User.id == current_user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + update_data = user_in.dict(exclude_unset=True) + if "password" in update_data and update_data["password"]: + update_data["hashed_password"] = get_password_hash(update_data["password"]) + del update_data["password"] + + for field, value in update_data.items(): + setattr(user, field, value) + + db.add(user) + db.commit() + db.refresh(user) + return user + + +@router.get("/{user_id}", response_model=UserSchema) +def read_user_by_id( + user_id: str, + db: Session = Depends(get_db), + current_user_id: str = Depends(get_current_user), +) -> Any: + """ + Get a specific user by id. + """ + user = db.query(User).filter(User.id == current_user_id).first() + if not user or not user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + return user + + +@router.get("/", response_model=List[UserSchema]) +def read_users( + db: Session = Depends(get_db), + current_user_id: str = Depends(get_current_user), + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Retrieve users. Only admin users can access this endpoint. + """ + user = db.query(User).filter(User.id == current_user_id).first() + if not user or not user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + users = db.query(User).offset(skip).limit(limit).all() + return users \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..904df48 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +# This file marks the core directory as a Python package \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..9906270 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,38 @@ +import os +from pathlib import Path +from typing import List + +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + # Project settings + PROJECT_NAME: str = "E-Commerce API" + PROJECT_DESCRIPTION: str = "FastAPI E-Commerce Application" + PROJECT_VERSION: str = "0.1.0" + + # API settings + API_V1_STR: str = "/api/v1" + + # JWT Settings + JWT_SECRET_KEY: str = os.environ.get("JWT_SECRET_KEY", "supersecretkey") + JWT_ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + + # Database settings + DB_DIR: Path = Path("/app") / "storage" / "db" + SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite" + + # CORS settings + CORS_ORIGINS: List[str] = ["*"] + + # Security settings + PASSWORD_HASH_ROUNDS: int = 12 + + class Config: + env_file = ".env" + case_sensitive = True + +# Create DB directory if it doesn't exist +Settings().DB_DIR.mkdir(parents=True, exist_ok=True) + +settings = Settings() \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..7e80834 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,63 @@ +from datetime import datetime, timedelta +from typing import Any, Optional, Union + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.db.session import get_db + +# Password hashing context +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# OAuth2 password bearer +oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login") + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify if plain password matches hashed password.""" + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + """Hash password.""" + return pwd_context.hash(password) + +def create_access_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str: + """Create JWT access token.""" + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + return encoded_jwt + +async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)): + """Get current user from token.""" + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode( + token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM] + ) + user_id: str = payload.get("sub") + if user_id is None: + raise credentials_exception + except JWTError: + raise credentials_exception + + # This will be implemented after we create the User model + # user = db.query(User).filter(User.id == user_id).first() + # if user is None: + # raise credentials_exception + # return user + + # For now, return the user_id + return user_id \ No newline at end of file diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..9312996 --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ +# This file marks the db directory as a Python package \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..2fd82f4 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,28 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from app.core.config import settings + +# Create DB directory if it doesn't exist +settings.DB_DIR.mkdir(parents=True, exist_ok=True) + +# Create engine +engine = create_engine( + settings.SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +# Create SessionLocal +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Create Base class +Base = declarative_base() + +# Dependency to get DB session +def get_db(): + 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..032200d --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,15 @@ +# Import all models here +from app.models.user import User +from app.models.product import Category, Product +from app.models.order import Order, OrderItem, OrderStatus +from app.models.cart import CartItem + +__all__ = [ + "User", + "Category", + "Product", + "Order", + "OrderItem", + "OrderStatus", + "CartItem", +] \ No newline at end of file diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..63ccd3c --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,16 @@ +import uuid +from datetime import datetime + +from sqlalchemy import Column, DateTime +from sqlalchemy.dialects.sqlite import TEXT +from sqlalchemy.ext.declarative import declared_attr + + +class Base: + @declared_attr + def __tablename__(cls): + return cls.__name__.lower() + + id = Column(TEXT, primary_key=True, default=lambda: str(uuid.uuid4())) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) \ No newline at end of file diff --git a/app/models/cart.py b/app/models/cart.py new file mode 100644 index 0000000..8290984 --- /dev/null +++ b/app/models/cart.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + +from app.db.session import Base +from app.models.base import Base as BaseModel + + +class CartItem(Base, BaseModel): + """Cart item model.""" + + quantity = Column(Integer, nullable=False, default=1) + + # Foreign keys + user_id = Column(String(36), ForeignKey("user.id"), nullable=False) + product_id = Column(String(36), ForeignKey("product.id"), nullable=False) + + # Relationships + user = relationship("User", back_populates="cart_items") + product = relationship("Product", back_populates="cart_items") \ No newline at end of file diff --git a/app/models/order.py b/app/models/order.py new file mode 100644 index 0000000..8ef5186 --- /dev/null +++ b/app/models/order.py @@ -0,0 +1,49 @@ +from enum import Enum as PyEnum + +from sqlalchemy import Column, Enum, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.db.session import Base +from app.models.base import Base as BaseModel + + +class OrderStatus(str, PyEnum): + """Order status enum.""" + + PENDING = "pending" + PAID = "paid" + SHIPPED = "shipped" + DELIVERED = "delivered" + CANCELLED = "cancelled" + + +class Order(Base, BaseModel): + """Order model.""" + + total_amount = Column(Float, nullable=False) + status = Column(Enum(OrderStatus), default=OrderStatus.PENDING, nullable=False) + shipping_address = Column(Text, nullable=True) + tracking_number = Column(String(100), nullable=True) + payment_id = Column(String(100), nullable=True) + + # Foreign keys + user_id = Column(String(36), ForeignKey("user.id"), nullable=False) + + # Relationships + user = relationship("User", back_populates="orders") + items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan") + + +class OrderItem(Base, BaseModel): + """Order item model.""" + + quantity = Column(Integer, nullable=False) + price = Column(Float, nullable=False) # Price at the time of purchase + + # Foreign keys + order_id = Column(String(36), ForeignKey("order.id"), nullable=False) + product_id = Column(String(36), ForeignKey("product.id"), nullable=False) + + # Relationships + order = relationship("Order", back_populates="items") + product = relationship("Product", back_populates="order_items") \ No newline at end of file diff --git a/app/models/product.py b/app/models/product.py new file mode 100644 index 0000000..bef6494 --- /dev/null +++ b/app/models/product.py @@ -0,0 +1,34 @@ +from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.db.session import Base +from app.models.base import Base as BaseModel + + +class Category(Base, BaseModel): + """Category model.""" + + name = Column(String(100), nullable=False, unique=True) + description = Column(Text, nullable=True) + + # Relationships + products = relationship("Product", back_populates="category") + + +class Product(Base, BaseModel): + """Product model.""" + + name = Column(String(255), nullable=False, index=True) + description = Column(Text, nullable=True) + price = Column(Float, nullable=False) + stock_quantity = Column(Integer, nullable=False, default=0) + is_active = Column(Boolean, default=True) + image_url = Column(String(255), nullable=True) + + # Foreign keys + category_id = Column(String(36), ForeignKey("category.id"), nullable=True) + + # Relationships + category = relationship("Category", back_populates="products") + order_items = relationship("OrderItem", back_populates="product") + cart_items = relationship("CartItem", 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..fc5e92a --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,21 @@ +from sqlalchemy import Boolean, Column, String, Text +from sqlalchemy.orm import relationship + +from app.db.session import Base +from app.models.base import Base as BaseModel + + +class User(Base, BaseModel): + """User model.""" + + email = Column(String(255), unique=True, index=True, nullable=False) + hashed_password = Column(String(255), nullable=False) + full_name = Column(String(255), nullable=True) + phone = Column(String(20), nullable=True) + address = Column(Text, nullable=True) + is_active = Column(Boolean, default=True) + is_admin = Column(Boolean, default=False) + + # Relationships + orders = relationship("Order", back_populates="user", cascade="all, delete-orphan") + cart_items = relationship("CartItem", 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..5926a8f --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,29 @@ +# Import all schemas here for easier access +from app.schemas.user import User, UserCreate, UserUpdate +from app.schemas.product import Category, CategoryCreate, CategoryUpdate, Product, ProductCreate, ProductUpdate +from app.schemas.order import Order, OrderCreate, OrderItem, OrderItemCreate, OrderUpdate +from app.schemas.cart import Cart, CartItem, CartItemCreate, CartItemUpdate +from app.schemas.token import Token, TokenPayload + +__all__ = [ + "User", + "UserCreate", + "UserUpdate", + "Category", + "CategoryCreate", + "CategoryUpdate", + "Product", + "ProductCreate", + "ProductUpdate", + "Order", + "OrderCreate", + "OrderItem", + "OrderItemCreate", + "OrderUpdate", + "Cart", + "CartItem", + "CartItemCreate", + "CartItemUpdate", + "Token", + "TokenPayload", +] \ No newline at end of file diff --git a/app/schemas/cart.py b/app/schemas/cart.py new file mode 100644 index 0000000..742ee14 --- /dev/null +++ b/app/schemas/cart.py @@ -0,0 +1,35 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + +from app.schemas.product import Product + + +class CartItemBase(BaseModel): + product_id: str + quantity: int = Field(..., gt=0) + + +class CartItemCreate(CartItemBase): + pass + + +class CartItemUpdate(BaseModel): + quantity: int = Field(..., gt=0) + + +class CartItem(CartItemBase): + id: str + user_id: str + product: Optional[Product] = None + + class Config: + orm_mode = True + + +class Cart(BaseModel): + items: List[CartItem] = [] + total: float = 0 + + class Config: + orm_mode = True \ No newline at end of file diff --git a/app/schemas/order.py b/app/schemas/order.py new file mode 100644 index 0000000..faeeb11 --- /dev/null +++ b/app/schemas/order.py @@ -0,0 +1,53 @@ +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + +from app.models.order import OrderStatus + + +# OrderItem schemas +class OrderItemBase(BaseModel): + product_id: str + quantity: int = Field(..., gt=0) + price: float = Field(..., gt=0) + + +class OrderItemCreate(OrderItemBase): + pass + + +class OrderItem(OrderItemBase): + id: str + order_id: str + + class Config: + orm_mode = True + + +# Order schemas +class OrderBase(BaseModel): + shipping_address: Optional[str] = None + tracking_number: Optional[str] = None + payment_id: Optional[str] = None + status: OrderStatus = OrderStatus.PENDING + + +class OrderCreate(OrderBase): + items: List[OrderItemCreate] + + +class OrderUpdate(OrderBase): + pass + + +class Order(OrderBase): + id: str + user_id: str + total_amount: float + created_at: datetime + updated_at: datetime + items: List[OrderItem] = [] + + class Config: + orm_mode = True \ No newline at end of file diff --git a/app/schemas/product.py b/app/schemas/product.py new file mode 100644 index 0000000..0c2d0b1 --- /dev/null +++ b/app/schemas/product.py @@ -0,0 +1,53 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +# Category schemas +class CategoryBase(BaseModel): + name: str + description: Optional[str] = None + + +class CategoryCreate(CategoryBase): + pass + + +class CategoryUpdate(CategoryBase): + name: Optional[str] = None + + +class Category(CategoryBase): + id: str + + class Config: + orm_mode = True + + +# Product schemas +class ProductBase(BaseModel): + name: str + description: Optional[str] = None + price: float = Field(..., gt=0) + stock_quantity: int = Field(..., ge=0) + is_active: bool = True + image_url: Optional[str] = None + category_id: Optional[str] = None + + +class ProductCreate(ProductBase): + pass + + +class ProductUpdate(ProductBase): + name: Optional[str] = None + price: Optional[float] = Field(None, gt=0) + stock_quantity: Optional[int] = Field(None, ge=0) + + +class Product(ProductBase): + id: str + category: Optional[Category] = None + + class Config: + orm_mode = True \ No newline at end of file diff --git a/app/schemas/token.py b/app/schemas/token.py new file mode 100644 index 0000000..ee642be --- /dev/null +++ b/app/schemas/token.py @@ -0,0 +1,12 @@ +from typing import Optional + +from pydantic import BaseModel + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenPayload(BaseModel): + sub: Optional[str] = None \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..a5a2304 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,39 @@ +from typing import Optional + +from pydantic import BaseModel, EmailStr, Field, validator + + +# Shared properties +class UserBase(BaseModel): + email: EmailStr + full_name: Optional[str] = None + phone: Optional[str] = None + address: Optional[str] = None + is_active: bool = True + is_admin: bool = False + + +# Properties to receive via API on creation +class UserCreate(UserBase): + password: str = Field(..., min_length=8) + password_confirm: str + + @validator('password_confirm') + def passwords_match(cls, v, values, **kwargs): + if 'password' in values and v != values['password']: + raise ValueError('passwords do not match') + return v + + +# Properties to receive via API on update +class UserUpdate(UserBase): + email: Optional[EmailStr] = None + password: Optional[str] = Field(None, min_length=8) + + +# Properties to return via API +class User(UserBase): + id: str + + class Config: + orm_mode = True \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..10419c0 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# This file marks the services directory as a Python package \ No newline at end of file diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..cd41903 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1 @@ +# This file marks the utils directory as a Python package \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..5f16b88 --- /dev/null +++ b/main.py @@ -0,0 +1,35 @@ +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1.api import api_router +from app.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, + description=settings.PROJECT_DESCRIPTION, + version=settings.PROJECT_VERSION, + openapi_url="/openapi.json", + docs_url="/docs", + redoc_url="/redoc", +) + +# Set up CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include API router +app.include_router(api_router) + +# Root health check endpoint +@app.get("/health", tags=["health"]) +async def health_check(): + return {"status": "healthy"} + +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..fed0414 --- /dev/null +++ b/migrations/__init__.py @@ -0,0 +1 @@ +# This file marks the migrations directory as a Python package \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..30c16eb --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,80 @@ +import os +import sys +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +# Add the parent directory to sys.path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +# Import Base and all models +from app.db.session import Base +from app.models import * # noqa + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + is_sqlite = connection.dialect.name == 'sqlite' + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=is_sqlite, # Key configuration for SQLite + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1e4564e --- /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(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/migrations/versions/0001_create_tables.py b/migrations/versions/0001_create_tables.py new file mode 100644 index 0000000..d78970c --- /dev/null +++ b/migrations/versions/0001_create_tables.py @@ -0,0 +1,114 @@ +"""create tables + +Revision ID: 0001 +Revises: +Create Date: 2023-08-31 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0001' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # Create user table + op.create_table('user', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('hashed_password', sa.String(length=255), nullable=False), + sa.Column('full_name', sa.String(length=255), nullable=True), + sa.Column('phone', sa.String(length=20), nullable=True), + sa.Column('address', sa.Text(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('is_admin', sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) + + # Create category table + op.create_table('category', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + + # Create product table + op.create_table('product', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('stock_quantity', sa.Integer(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('image_url', sa.String(length=255), nullable=True), + sa.Column('category_id', sa.String(length=36), nullable=True), + sa.ForeignKeyConstraint(['category_id'], ['category.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_product_name'), 'product', ['name'], unique=False) + + # Create order table + op.create_table('order', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('total_amount', sa.Float(), nullable=False), + sa.Column('status', sa.Enum('pending', 'paid', 'shipped', 'delivered', 'cancelled', name='orderstatus'), nullable=False), + sa.Column('shipping_address', sa.Text(), nullable=True), + sa.Column('tracking_number', sa.String(length=100), nullable=True), + sa.Column('payment_id', sa.String(length=100), nullable=True), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + # Create order_item table + op.create_table('order_item', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('order_id', sa.String(length=36), nullable=False), + sa.Column('product_id', sa.String(length=36), nullable=False), + sa.ForeignKeyConstraint(['order_id'], ['order.id'], ), + sa.ForeignKeyConstraint(['product_id'], ['product.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + # Create cart_item table + op.create_table('cart_item', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('product_id', sa.String(length=36), nullable=False), + sa.ForeignKeyConstraint(['product_id'], ['product.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + +def downgrade(): + op.drop_table('cart_item') + op.drop_table('order_item') + op.drop_table('order') + op.drop_table('product') + op.drop_table('category') + op.drop_table('user') + op.execute('DROP TYPE orderstatus') \ No newline at end of file diff --git a/migrations/versions/__init__.py b/migrations/versions/__init__.py new file mode 100644 index 0000000..49cb8fa --- /dev/null +++ b/migrations/versions/__init__.py @@ -0,0 +1 @@ +# This file marks the versions directory as a Python package \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e477cc9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +fastapi>=0.103.1 +uvicorn>=0.23.2 +sqlalchemy>=2.0.20 +alembic>=1.12.0 +pydantic>=2.3.0 +pydantic-settings>=2.0.3 +python-jose>=3.3.0 +passlib>=1.7.4 +bcrypt>=4.0.1 +python-multipart>=0.0.6 +email-validator>=2.0.0 +ruff>=0.0.291 +python-dotenv>=1.0.0 \ No newline at end of file