Implement simple ecommerce API with FastAPI and SQLite

- Setup project structure and FastAPI application
- Create SQLAlchemy models for users, products, carts, and orders
- Implement Alembic migrations
- Add CRUD operations and endpoints for all resources
- Setup authentication with JWT
- Add role-based access control
- Update documentation
This commit is contained in:
Automated Action 2025-06-12 17:15:18 +00:00
parent c630241780
commit b078a91dd3
40 changed files with 2328 additions and 2 deletions

129
README.md
View File

@ -1,3 +1,128 @@
# FastAPI Application
# Simple Ecommerce API
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform.
A simple ecommerce API built with FastAPI and SQLite.
## Features
- User authentication (register, login)
- Product management (CRUD)
- Shopping cart functionality
- Order management
- Role-based access control (admin/regular users)
## Tech Stack
- Python 3.9+
- FastAPI
- SQLAlchemy ORM
- Alembic for database migrations
- SQLite for database
- JWT for authentication
## API Endpoints
### Authentication
- `POST /api/v1/auth/register`: Register a new user
- `POST /api/v1/auth/login`: Login and get access token
### Products
- `GET /api/v1/products`: List all products
- `GET /api/v1/products/{id}`: Get a specific product
- `POST /api/v1/products`: Create a new product (admin only)
- `PUT /api/v1/products/{id}`: Update a product (admin only)
- `DELETE /api/v1/products/{id}`: Delete a product (admin only)
### Cart
- `GET /api/v1/cart`: Get current user's cart
- `POST /api/v1/cart/items`: Add an item to cart
- `PUT /api/v1/cart/items/{id}`: Update cart item quantity
- `DELETE /api/v1/cart/items/{id}`: Remove an item from cart
- `DELETE /api/v1/cart`: Clear the cart
### Orders
- `GET /api/v1/orders`: List user's orders (or all orders for admin)
- `POST /api/v1/orders`: Create a new order from cart
- `GET /api/v1/orders/{id}`: Get a specific order with details
- `PUT /api/v1/orders/{id}/status`: Update order status (admin only)
- `DELETE /api/v1/orders/{id}`: Cancel an order
### Users
- `GET /api/v1/users`: List all users (admin only)
- `GET /api/v1/users/me`: Get current user details
- `PUT /api/v1/users/me`: Update current user details
- `GET /api/v1/users/{id}`: Get a specific user (admin only)
- `PUT /api/v1/users/{id}`: Update a user (admin only)
- `DELETE /api/v1/users/{id}`: Delete a user (admin only)
## Project Structure
```
.
├── alembic.ini # Alembic configuration
├── app/ # Main application package
│ ├── api/ # API endpoints
│ │ └── v1/ # API version 1
│ ├── core/ # Core functionality
│ ├── crud/ # Database CRUD operations
│ ├── db/ # Database session and models
│ ├── models/ # SQLAlchemy models
│ └── schemas/ # Pydantic schemas
├── main.py # Application entry point
├── migrations/ # Database migrations
└── requirements.txt # Project dependencies
```
## Getting Started
### Prerequisites
- Python 3.9+
### Installation
1. Clone the repository:
```
git clone <repository-url>
cd simple-ecommerce-api
```
2. Install dependencies:
```
pip install -r requirements.txt
```
3. Set up environment variables:
```
export SECRET_KEY="your-secret-key"
export API_URL="http://localhost:8000" # Optional, default is http://localhost:8000
```
4. Run database migrations:
```
alembic upgrade head
```
5. Start the server:
```
uvicorn main:app --reload
```
6. Open your browser and navigate to `http://localhost:8000/docs` to see the interactive API documentation.
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| SECRET_KEY | JWT secret key | "supersecretkey" |
| API_URL | Base URL for the API | "http://localhost:8000" |
| FIRST_SUPERUSER_EMAIL | Email for initial superuser | None |
| FIRST_SUPERUSER_PASSWORD | Password for initial superuser | None |
## License
This project is licensed under the MIT License.

85
alembic.ini Normal file
View File

@ -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 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
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

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

@ -0,0 +1,11 @@
from fastapi import APIRouter
from app.api.v1 import products, users, cart, orders, auth
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(products.router, prefix="/products", tags=["products"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(cart.router, prefix="/cart", tags=["cart"])
api_router.include_router(orders.router, prefix="/orders", tags=["orders"])

75
app/api/v1/auth.py Normal file
View File

@ -0,0 +1,75 @@
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
from app.crud.user import user as user_crud
from app.core.deps import get_db
from app.schemas.token import Token
from app.schemas.user import User
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 = user_crud.authenticate(
db, email=form_data.username, password=form_data.password
)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
elif not user_crud.is_active(user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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=User)
def register_user(
*,
db: Session = Depends(get_db),
email: str,
password: str,
full_name: str = None,
) -> Any:
"""
Register a new user.
"""
user = user_crud.get_by_email(db, email=email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this email already exists in the system",
)
from app.schemas.user import UserCreate
user_in = UserCreate(
email=email,
password=password,
full_name=full_name,
is_superuser=False,
is_active=True,
)
user = user_crud.create(db, obj_in=user_in)
return user

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

@ -0,0 +1,147 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.v1.deps import get_current_active_user
from app.core.deps import get_db
from app.crud.cart import cart_item as cart_item_crud
from app.crud.product import product as product_crud
from app.models.user import User
from app.schemas.cart import (
Cart,
CartItem,
CartItemCreate,
CartItemUpdate,
CartItemWithProduct,
)
from app.schemas.product import Product
router = APIRouter()
@router.get("/", response_model=Cart)
def read_cart(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve current user's cart.
"""
cart_items = cart_item_crud.get_user_cart(db, user_id=current_user.id)
# Fetch product details for each cart item
items_with_products = []
total = 0.0
for item in cart_items:
product = product_crud.get(db, id=item.product_id)
if product:
item_with_product = CartItemWithProduct.from_orm(item)
item_with_product.product = Product.from_orm(product)
items_with_products.append(item_with_product)
total += product.price * item.quantity
return {"items": items_with_products, "total": total}
@router.post("/items", response_model=CartItem)
def add_cart_item(
*,
db: Session = Depends(get_db),
item_in: CartItemCreate,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Add item to cart.
"""
# Check if product exists and is in stock
product = product_crud.get(db, id=item_in.product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)
if product.stock < item_in.quantity:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Not enough stock available. Only {product.stock} items left.",
)
# Create or update cart item
cart_item = cart_item_crud.create_or_update(
db, user_id=current_user.id, obj_in=item_in
)
return cart_item
@router.put("/items/{cart_item_id}", response_model=CartItem)
def update_cart_item(
*,
db: Session = Depends(get_db),
cart_item_id: int,
item_in: CartItemUpdate,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Update quantity of cart item.
"""
cart_item = cart_item_crud.get(db, id=cart_item_id)
if not cart_item or cart_item.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Cart item not found",
)
# Check if product has enough stock
product = product_crud.get(db, id=cart_item.product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)
if product.stock < item_in.quantity:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Not enough stock available. Only {product.stock} items left.",
)
cart_item = cart_item_crud.update(db, db_obj=cart_item, obj_in=item_in)
return cart_item
@router.delete(
"/items/{cart_item_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None
)
def remove_cart_item(
*,
db: Session = Depends(get_db),
cart_item_id: int,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Remove item from cart.
"""
cart_item = cart_item_crud.get(db, id=cart_item_id)
if not cart_item or cart_item.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Cart item not found",
)
cart_item_crud.remove(db, id=cart_item_id)
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_active_user),
) -> Any:
"""
Clear all items from cart.
"""
cart_item_crud.clear_cart(db, user_id=current_user.id)
return None

1
app/api/v1/deps.py Normal file
View File

@ -0,0 +1 @@
# Re-export dependencies from core.deps

224
app/api/v1/orders.py Normal file
View File

@ -0,0 +1,224 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.v1.deps import get_current_active_superuser, get_current_active_user
from app.core.deps import get_db
from app.crud.cart import cart_item as cart_item_crud
from app.crud.order import order as order_crud, order_item as order_item_crud
from app.crud.product import product as product_crud
from app.models.order import OrderStatus
from app.models.user import User
from app.schemas.order import Order, OrderCreate, OrderWithItems, OrderItemCreate
router = APIRouter()
@router.get("/", response_model=List[Order])
def read_orders(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Retrieve orders.
If user is superuser, returns all orders with pagination.
Otherwise, returns only the current user's orders.
"""
if current_user.is_superuser:
orders = order_crud.get_multi(db, skip=skip, limit=limit)
else:
orders = order_crud.get_user_orders(db, user_id=current_user.id)
return orders
@router.post("/", response_model=Order)
def create_order(
*,
db: Session = Depends(get_db),
order_in: OrderCreate,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Create new order from user's cart.
"""
# Get user's cart
cart_items = cart_item_crud.get_user_cart(db, user_id=current_user.id)
if not cart_items:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot create order with empty cart",
)
# Check stock and prepare order items
order_items = []
total_amount = 0.0
for cart_item in cart_items:
product = product_crud.get(db, id=cart_item.product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Product with ID {cart_item.product_id} not found",
)
if product.stock < cart_item.quantity:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Not enough stock for product '{product.name}'. Only {product.stock} available.",
)
# Create order item
order_item = OrderItemCreate(
product_id=cart_item.product_id,
quantity=cart_item.quantity,
)
order_item.unit_price = (
product.price
) # Add unit price from current product price
order_items.append(order_item)
# Update total amount
total_amount += product.price * cart_item.quantity
# Update product stock
product_crud.update_stock(
db, product_id=product.id, quantity=-cart_item.quantity
)
# Create order
order = order_crud.create_with_items(
db,
obj_in=order_in,
user_id=current_user.id,
items=order_items,
total_amount=total_amount,
)
# Clear cart
cart_item_crud.clear_cart(db, user_id=current_user.id)
return order
@router.get("/{order_id}", response_model=OrderWithItems)
def read_order(
*,
db: Session = Depends(get_db),
order_id: int,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Get order by ID with its items.
"""
order = order_crud.get(db, id=order_id)
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Order not found",
)
# Check if user has permission to access this order
if not current_user.is_superuser and order.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to access this order",
)
# Get order items
order_items = order_item_crud.get_by_order(db, order_id=order.id)
# Fetch product details for each order item
items_with_products = []
for item in order_items:
product = product_crud.get(db, id=item.product_id)
if product:
from app.schemas.order import OrderItemWithProduct
item_with_product = OrderItemWithProduct.from_orm(item)
item_with_product.product = product
items_with_products.append(item_with_product)
# Create response
from app.schemas.order import OrderWithItems
order_with_items = OrderWithItems.from_orm(order)
order_with_items.items = items_with_products
return order_with_items
@router.put("/{order_id}/status", response_model=Order)
def update_order_status(
*,
db: Session = Depends(get_db),
order_id: int,
status: OrderStatus,
current_user: User = Depends(get_current_active_superuser),
) -> Any:
"""
Update order status.
Only superusers can update order status.
"""
order = order_crud.get(db, id=order_id)
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Order not found",
)
order = order_crud.update_status(db, order_id=order_id, status=status)
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: int,
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Cancel an order.
Superusers can cancel any order.
Regular users can only cancel their own orders and only if the order is still pending.
"""
order = order_crud.get(db, id=order_id)
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Order not found",
)
# Check permissions
if not current_user.is_superuser and order.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to cancel this order",
)
# Regular users can only cancel pending orders
if not current_user.is_superuser and order.status != OrderStatus.PENDING:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot cancel order that is not in 'pending' status",
)
# Update order status to cancelled
order_crud.update_status(db, order_id=order_id, status=OrderStatus.CANCELLED)
# Return stock to inventory
order_items = order_item_crud.get_by_order(db, order_id=order.id)
for item in order_items:
product_crud.update_stock(
db, product_id=item.product_id, quantity=item.quantity
)
return None

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

@ -0,0 +1,119 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.v1.deps import get_current_active_superuser
from app.core.deps import get_db
from app.crud.product import product as product_crud
from app.models.user import User
from app.schemas.product import Product, ProductCreate, ProductUpdate
router = APIRouter()
@router.get("/", response_model=List[Product])
def read_products(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
) -> Any:
"""
Retrieve products.
If search query is provided, returns products matching the search query.
Otherwise, returns all products with pagination.
"""
if search:
products = product_crud.search_products(
db, query=search, skip=skip, limit=limit
)
else:
products = product_crud.get_multi(db, skip=skip, limit=limit)
return products
@router.post("/", response_model=Product)
def create_product(
*,
db: Session = Depends(get_db),
product_in: ProductCreate,
current_user: User = Depends(get_current_active_superuser),
) -> Any:
"""
Create new product.
Only superusers can create products.
"""
product = product_crud.get_product_by_name(db, name=product_in.name)
if product:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A product with this name already exists",
)
product = product_crud.create(db, obj_in=product_in)
return product
@router.get("/{id}", response_model=Product)
def read_product(
*,
db: Session = Depends(get_db),
id: int,
) -> Any:
"""
Get product by ID.
"""
product = product_crud.get(db, id=id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)
return product
@router.put("/{id}", response_model=Product)
def update_product(
*,
db: Session = Depends(get_db),
id: int,
product_in: ProductUpdate,
current_user: User = Depends(get_current_active_superuser),
) -> Any:
"""
Update a product.
Only superusers can update products.
"""
product = product_crud.get(db, id=id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)
product = product_crud.update(db, db_obj=product, obj_in=product_in)
return product
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_product(
*,
db: Session = Depends(get_db),
id: int,
current_user: User = Depends(get_current_active_superuser),
) -> Any:
"""
Delete a product.
Only superusers can delete products.
"""
product = product_crud.get(db, id=id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)
product_crud.remove(db, id=id)
return None

148
app/api/v1/users.py Normal file
View File

@ -0,0 +1,148 @@
from typing import Any, List
from fastapi import APIRouter, Body, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.v1.deps import get_current_active_superuser, get_current_active_user
from app.core.deps import get_db
from app.crud.user import user as user_crud
from app.models.user import User
from app.schemas.user import User as UserSchema
from app.schemas.user import UserCreate, UserUpdate
router = APIRouter()
@router.get("/", response_model=List[UserSchema])
def read_users(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_active_superuser),
) -> Any:
"""
Retrieve users.
Only superusers can access this endpoint.
"""
users = user_crud.get_multi(db, skip=skip, limit=limit)
return users
@router.post("/", response_model=UserSchema)
def create_user(
*,
db: Session = Depends(get_db),
user_in: UserCreate,
current_user: User = Depends(get_current_active_superuser),
) -> Any:
"""
Create new user.
Only superusers can create other users.
"""
user = user_crud.get_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this email already exists in the system",
)
user = user_crud.create(db, obj_in=user_in)
return user
@router.get("/me", response_model=UserSchema)
def read_user_me(
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Get current user.
"""
return current_user
@router.put("/me", response_model=UserSchema)
def update_user_me(
*,
db: Session = Depends(get_db),
full_name: str = Body(None),
password: str = Body(None),
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
Update own user.
"""
user_in = UserUpdate()
if password is not None:
user_in.password = password
if full_name is not None:
user_in.full_name = full_name
user = user_crud.update(db, db_obj=current_user, obj_in=user_in)
return user
@router.get("/{user_id}", response_model=UserSchema)
def read_user_by_id(
user_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db),
) -> Any:
"""
Get a specific user by id.
"""
user = user_crud.get(db, id=user_id)
if user == current_user:
return user
if not user_crud.is_superuser(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The user doesn't have enough privileges",
)
return user
@router.put("/{user_id}", response_model=UserSchema)
def update_user(
*,
db: Session = Depends(get_db),
user_id: int,
user_in: UserUpdate,
current_user: User = Depends(get_current_active_superuser),
) -> Any:
"""
Update a user.
Only superusers can update other users.
"""
user = user_crud.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="The user with this ID does not exist in the system",
)
user = user_crud.update(db, db_obj=user, obj_in=user_in)
return user
@router.delete(
"/{user_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None
)
def delete_user(
*,
db: Session = Depends(get_db),
user_id: int,
current_user: User = Depends(get_current_active_superuser),
) -> Any:
"""
Delete a user.
Only superusers can delete users.
"""
user = user_crud.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="The user with this ID does not exist in the system",
)
user_crud.remove(db, id=user_id)
return None

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

@ -0,0 +1,35 @@
import os
from pathlib import Path
from typing import Optional
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
API_V1_STR: str = "/api/v1"
PROJECT_NAME: str = "Simple Ecommerce API"
PROJECT_DESCRIPTION: str = "A simple ecommerce API built with FastAPI and SQLite"
VERSION: str = "0.1.0"
# Base API URL (without trailing slash)
API_URL: str = os.getenv("API_URL", "http://localhost:8000")
# JWT Secret key
SECRET_KEY: str = os.getenv("SECRET_KEY", "supersecretkey")
# 60 minutes * 24 hours * 8 days = 8 days
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
# Database
DB_DIR = Path("/app") / "storage" / "db"
DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite"
# First superuser
FIRST_SUPERUSER_EMAIL: Optional[str] = os.getenv("FIRST_SUPERUSER_EMAIL")
FIRST_SUPERUSER_PASSWORD: Optional[str] = os.getenv("FIRST_SUPERUSER_PASSWORD")
class Config:
case_sensitive = True
settings = Settings()

106
app/core/deps.py Normal file
View File

@ -0,0 +1,106 @@
from typing import Generator
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app.core.config import settings
from app.crud.user import user as user_crud
from app.db.session import SessionLocal
from app.models.user import User
from app.schemas.token import TokenPayload
reusable_oauth2 = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
def get_db() -> Generator:
"""
Dependency function that yields a SQLAlchemy database session.
The session is closed after the request is complete.
"""
try:
db = SessionLocal()
yield db
finally:
db.close()
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(reusable_oauth2)
) -> User:
"""
Decode JWT token to get user identity and fetch user object from database.
Args:
db: Database session
token: JWT token
Returns:
User object
Raises:
HTTPException: If token is invalid or user not found
"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
token_data = TokenPayload(**payload)
except (JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user = user_crud.get(db, id=token_data.sub)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
return user
def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Check if current user is active.
Args:
current_user: Current user
Returns:
User object
Raises:
HTTPException: If user is inactive
"""
if not user_crud.is_active(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
)
return current_user
def get_current_active_superuser(
current_user: User = Depends(get_current_user),
) -> User:
"""
Check if current user is a superuser.
Args:
current_user: Current user
Returns:
User object
Raises:
HTTPException: If user is not a superuser
"""
if not user_crud.is_superuser(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The user doesn't have enough privileges",
)
return current_user

60
app/core/security.py Normal file
View File

@ -0,0 +1,60 @@
from datetime import datetime, timedelta
from typing import Any, Union
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def create_access_token(
subject: Union[str, Any], expires_delta: timedelta = None
) -> str:
"""
Create a new access token for a user.
Args:
subject: The subject of the token (typically the user ID)
expires_delta: Optional expiration time for the token
Returns:
The encoded JWT 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.SECRET_KEY, algorithm="HS256")
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify that a plain password matches a hashed password.
Args:
plain_password: The plain text password to check
hashed_password: The hashed password to compare against
Returns:
True if the passwords match, False otherwise
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Hash a password using the configured password context.
Args:
password: The plain text password to hash
Returns:
The hashed password
"""
return pwd_context.hash(password)

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

@ -0,0 +1 @@
# Empty init file to make the directory a proper Python package

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

@ -0,0 +1,116 @@
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.base import Base
ModelType = TypeVar("ModelType", bound=Base)
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
def __init__(self, model: Type[ModelType]):
"""
CRUD object with default methods to Create, Read, Update, Delete (CRUD).
Args:
model: A SQLAlchemy model class
"""
self.model = model
def get(self, db: Session, id: Any) -> Optional[ModelType]:
"""
Get a single record by ID.
Args:
db: Database session
id: Record ID
Returns:
The model instance or None if not found
"""
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 with pagination.
Args:
db: Database session
skip: Number of records to skip
limit: Maximum number of records to return
Returns:
List of model instances
"""
return db.query(self.model).offset(skip).limit(limit).all()
def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
"""
Create a new record.
Args:
db: Database session
obj_in: Schema containing data for the new record
Returns:
The created model instance
"""
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.
Args:
db: Database session
db_obj: Model instance to update
obj_in: New data as schema or dict
Returns:
The updated model instance
"""
obj_data = jsonable_encoder(db_obj)
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.dict(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:
"""
Delete a record.
Args:
db: Database session
id: Record ID
Returns:
The deleted model instance
"""
obj = db.query(self.model).get(id)
db.delete(obj)
db.commit()
return obj

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

@ -0,0 +1,91 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.cart import CartItem
from app.schemas.cart import CartItemCreate, CartItemUpdate
class CRUDCartItem(CRUDBase[CartItem, CartItemCreate, CartItemUpdate]):
def get_by_user_and_product(
self, db: Session, *, user_id: int, product_id: int
) -> Optional[CartItem]:
"""
Get a cart item by user ID and product ID.
Args:
db: Database session
user_id: User ID
product_id: Product ID
Returns:
The cart item or None if not found
"""
return (
db.query(self.model)
.filter(self.model.user_id == user_id, self.model.product_id == product_id)
.first()
)
def get_user_cart(self, db: Session, *, user_id: int) -> List[CartItem]:
"""
Get all cart items for a user.
Args:
db: Database session
user_id: User ID
Returns:
List of cart items
"""
return db.query(self.model).filter(self.model.user_id == user_id).all()
def create_or_update(
self, db: Session, *, user_id: int, obj_in: CartItemCreate
) -> CartItem:
"""
Create a new cart item or update the quantity if it already exists.
Args:
db: Database session
user_id: User ID
obj_in: Cart item data
Returns:
The created or updated cart item
"""
cart_item = self.get_by_user_and_product(
db, user_id=user_id, product_id=obj_in.product_id
)
if cart_item:
# Update existing cart item
cart_item.quantity += obj_in.quantity
db.add(cart_item)
db.commit()
db.refresh(cart_item)
return cart_item
else:
# Create new cart item
db_obj = CartItem(
user_id=user_id, product_id=obj_in.product_id, quantity=obj_in.quantity
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def clear_cart(self, db: Session, *, user_id: int) -> None:
"""
Remove all cart items for a user.
Args:
db: Database session
user_id: User ID
"""
db.query(self.model).filter(self.model.user_id == user_id).delete()
db.commit()
cart_item = CRUDCartItem(CartItem)

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

@ -0,0 +1,113 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.order import Order, OrderItem, OrderStatus
from app.schemas.order import OrderCreate, OrderUpdate, OrderItemCreate
class CRUDOrder(CRUDBase[Order, OrderCreate, OrderUpdate]):
def get_user_orders(self, db: Session, *, user_id: int) -> List[Order]:
"""
Get all orders for a user.
Args:
db: Database session
user_id: User ID
Returns:
List of orders
"""
return db.query(self.model).filter(self.model.user_id == user_id).all()
def create_with_items(
self,
db: Session,
*,
obj_in: OrderCreate,
user_id: int,
items: List[OrderItemCreate],
total_amount: float,
) -> Order:
"""
Create a new order with order items.
Args:
db: Database session
obj_in: Order data
user_id: User ID
items: List of order items
total_amount: Total order amount
Returns:
The created order with items
"""
# Create order
order_data = obj_in.dict()
db_obj = Order(
**order_data,
user_id=user_id,
total_amount=total_amount,
status=OrderStatus.PENDING,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
# Create order items
for item in items:
order_item = OrderItem(
order_id=db_obj.id,
product_id=item.product_id,
quantity=item.quantity,
unit_price=item.unit_price if hasattr(item, "unit_price") else 0,
)
db.add(order_item)
db.commit()
db.refresh(db_obj)
return db_obj
def update_status(
self, db: Session, *, order_id: int, status: OrderStatus
) -> Optional[Order]:
"""
Update the status of an order.
Args:
db: Database session
order_id: Order ID
status: New order status
Returns:
The updated order or None if not found
"""
order = self.get(db, id=order_id)
if not order:
return None
order.status = status
db.add(order)
db.commit()
db.refresh(order)
return order
class CRUDOrderItem(CRUDBase[OrderItem, OrderItemCreate, OrderItemCreate]):
def get_by_order(self, db: Session, *, order_id: int) -> List[OrderItem]:
"""
Get all items for an order.
Args:
db: Database session
order_id: Order ID
Returns:
List of order items
"""
return db.query(self.model).filter(self.model.order_id == order_id).all()
order = CRUDOrder(Order)
order_item = CRUDOrderItem(OrderItem)

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

@ -0,0 +1,71 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.product import Product
from app.schemas.product import ProductCreate, ProductUpdate
class CRUDProduct(CRUDBase[Product, ProductCreate, ProductUpdate]):
def search_products(
self, db: Session, *, query: str, skip: int = 0, limit: int = 100
) -> List[Product]:
"""
Search for products by name or description.
Args:
db: Database session
query: Search query
skip: Number of records to skip
limit: Maximum number of records to return
Returns:
List of products matching the search query
"""
search_pattern = f"%{query}%"
return (
db.query(self.model)
.filter(
(self.model.name.ilike(search_pattern))
| (self.model.description.ilike(search_pattern))
)
.offset(skip)
.limit(limit)
.all()
)
def get_product_by_name(self, db: Session, *, name: str) -> Optional[Product]:
"""
Get a product by its exact name.
Args:
db: Database session
name: Product name
Returns:
The product or None if not found
"""
return db.query(self.model).filter(self.model.name == name).first()
def update_stock(self, db: Session, *, product_id: int, quantity: int) -> Product:
"""
Update product stock quantity.
Args:
db: Database session
product_id: Product ID
quantity: Quantity to add (positive) or subtract (negative)
Returns:
The updated product
"""
product = self.get(db, id=product_id)
if product:
product.stock += quantity
db.commit()
db.refresh(product)
return product
product = CRUDProduct(Product)

116
app/crud/user.py Normal file
View File

@ -0,0 +1,116 @@
from typing import Any, Dict, Optional, Union
from sqlalchemy.orm import Session
from app.core.security import get_password_hash, verify_password
from app.crud.base import CRUDBase
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
def get_by_email(self, db: Session, *, email: str) -> Optional[User]:
"""
Get a user by email.
Args:
db: Database session
email: User email
Returns:
The user or None if not found
"""
return db.query(User).filter(User.email == email).first()
def create(self, db: Session, *, obj_in: UserCreate) -> User:
"""
Create a new user.
Args:
db: Database session
obj_in: User data including password
Returns:
The created user
"""
db_obj = User(
email=obj_in.email,
hashed_password=get_password_hash(obj_in.password),
full_name=obj_in.full_name,
is_superuser=obj_in.is_superuser,
is_active=obj_in.is_active,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]
) -> User:
"""
Update a user.
Args:
db: Database session
db_obj: User instance to update
obj_in: New user data
Returns:
The updated user
"""
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.dict(exclude_unset=True)
if "password" in update_data and update_data["password"]:
hashed_password = get_password_hash(update_data["password"])
del update_data["password"]
update_data["hashed_password"] = hashed_password
return super().update(db, db_obj=db_obj, obj_in=update_data)
def authenticate(self, db: Session, *, email: str, password: str) -> Optional[User]:
"""
Authenticate a user.
Args:
db: Database session
email: User email
password: User password
Returns:
The authenticated user or None if authentication fails
"""
user = self.get_by_email(db, email=email)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def is_active(self, user: User) -> bool:
"""
Check if a user is active.
Args:
user: User instance
Returns:
True if the user is active, False otherwise
"""
return user.is_active
def is_superuser(self, user: User) -> bool:
"""
Check if a user is a superuser.
Args:
user: User instance
Returns:
True if the user is a superuser, False otherwise
"""
return user.is_superuser
user = CRUDUser(User)

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

@ -0,0 +1 @@
# Empty init file to make the directory a proper Python package

3
app/db/base.py Normal file
View File

@ -0,0 +1,3 @@
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()

2
app/db/base_class.py Normal file
View File

@ -0,0 +1,2 @@
# Import all the models, so that Base has them before being
# imported by Alembic

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

@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(
settings.SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}, # Only needed for SQLite
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
"""
Dependency function that yields a SQLAlchemy database session.
The session is closed after the request is complete.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()

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

@ -0,0 +1,5 @@
# Import models for easier access
from app.models.user import User # noqa: F401
from app.models.product import Product # noqa: F401
from app.models.order import Order, OrderItem, OrderStatus # noqa: F401
from app.models.cart import CartItem # noqa: F401

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

@ -0,0 +1,20 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer
from sqlalchemy.orm import relationship
from app.db.base import Base
class CartItem(Base):
__tablename__ = "cart_items"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
quantity = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="cart_items")
product = relationship("Product", back_populates="cart_items")

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

@ -0,0 +1,47 @@
from datetime import datetime
from enum import Enum as PyEnum
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from app.db.base import Base
class OrderStatus(str, PyEnum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
status = Column(Enum(OrderStatus), default=OrderStatus.PENDING, nullable=False)
total_amount = Column(Float, nullable=False)
shipping_address = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="orders")
items = relationship(
"OrderItem", back_populates="order", cascade="all, delete-orphan"
)
class OrderItem(Base):
__tablename__ = "order_items"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
quantity = Column(Integer, nullable=False)
unit_price = Column(Float, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
order = relationship("Order", back_populates="items")
product = relationship("Product", back_populates="order_items")

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

@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, Float, Integer, String, Text
from sqlalchemy.orm import relationship
from app.db.base import Base
class Product(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
description = Column(Text, nullable=True)
price = Column(Float, nullable=False)
stock = Column(Integer, default=0, nullable=False)
image_url = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
order_items = relationship("OrderItem", back_populates="product")
cart_items = relationship("CartItem", back_populates="product")

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

@ -0,0 +1,22 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String
from sqlalchemy.orm import relationship
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
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)
is_superuser = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
orders = relationship("Order", back_populates="user")
cart_items = relationship("CartItem", back_populates="user")

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

@ -0,0 +1 @@
# Empty init file to make the directory a proper Python package

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

@ -0,0 +1,51 @@
from datetime import datetime
from typing import List
from pydantic import BaseModel, Field
from app.schemas.product import Product
# Shared properties
class CartItemBase(BaseModel):
product_id: int
quantity: int = Field(..., gt=0)
# Properties to receive on cart item creation
class CartItemCreate(CartItemBase):
pass
# Properties to receive on cart item update
class CartItemUpdate(BaseModel):
quantity: int = Field(..., gt=0)
class CartItemInDBBase(CartItemBase):
id: int
user_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Additional properties to return via API
class CartItem(CartItemInDBBase):
pass
# Additional properties stored in DB
class CartItemInDB(CartItemInDBBase):
pass
class CartItemWithProduct(CartItem):
product: Product
class Cart(BaseModel):
items: List[CartItemWithProduct]
total: float

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

@ -0,0 +1,79 @@
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
from app.models.order import OrderStatus
from app.schemas.product import Product
# Shared properties
class OrderItemBase(BaseModel):
product_id: int
quantity: int = Field(..., gt=0)
# Properties to receive on order item creation
class OrderItemCreate(OrderItemBase):
pass
# Additional properties stored in DB for order item
class OrderItemInDBBase(OrderItemBase):
id: int
order_id: int
unit_price: float
created_at: datetime
class Config:
from_attributes = True
# Additional properties to return via API
class OrderItem(OrderItemInDBBase):
pass
class OrderItemWithProduct(OrderItem):
product: Product
# Shared properties for order
class OrderBase(BaseModel):
shipping_address: Optional[str] = None
# Properties to receive on order creation
class OrderCreate(OrderBase):
shipping_address: str
# Properties to receive on order update
class OrderUpdate(BaseModel):
status: OrderStatus
class OrderInDBBase(OrderBase):
id: int
user_id: int
status: OrderStatus
total_amount: float
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Additional properties to return via API
class Order(OrderInDBBase):
pass
# Additional properties stored in DB
class OrderInDB(OrderInDBBase):
pass
class OrderWithItems(Order):
items: List[OrderItemWithProduct]

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

@ -0,0 +1,44 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
# Shared properties
class ProductBase(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
price: Optional[float] = None
stock: Optional[int] = None
image_url: Optional[str] = None
# Properties to receive on product creation
class ProductCreate(ProductBase):
name: str
price: float = Field(..., gt=0)
stock: int = Field(..., ge=0)
# Properties to receive on product update
class ProductUpdate(ProductBase):
pass
class ProductInDBBase(ProductBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Additional properties to return via API
class Product(ProductInDBBase):
pass
# Additional properties stored in DB
class ProductInDB(ProductInDBBase):
pass

13
app/schemas/token.py Normal file
View File

@ -0,0 +1,13 @@
from typing import Optional
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
class TokenPayload(BaseModel):
sub: Optional[int] = None
is_superuser: bool = False

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

@ -0,0 +1,42 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
# Shared properties
class UserBase(BaseModel):
email: Optional[EmailStr] = None
is_active: Optional[bool] = True
is_superuser: bool = False
full_name: Optional[str] = None
# Properties to receive via API on creation
class UserCreate(UserBase):
email: EmailStr
password: str
# Properties to receive via API on update
class UserUpdate(UserBase):
password: Optional[str] = None
class UserInDBBase(UserBase):
id: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Additional properties to return via API
class User(UserInDBBase):
pass
# Additional properties stored in DB
class UserInDB(UserInDBBase):
hashed_password: str

47
main.py Normal file
View File

@ -0,0 +1,47 @@
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.VERSION,
openapi_url="/openapi.json",
docs_url="/docs",
redoc_url="/redoc",
)
# Set up CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API router
app.include_router(api_router, prefix=settings.API_V1_STR)
@app.get("/")
async def root():
"""Root endpoint returning basic info about the API."""
return {
"title": settings.PROJECT_NAME,
"docs": f"{settings.API_URL}/docs",
"health": f"{settings.API_URL}/health",
}
@app.get("/health", status_code=200)
async def health_check():
"""Health check endpoint."""
return {"status": "healthy"}
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

1
migrations/README Normal file
View File

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

1
migrations/__init__.py Normal file
View File

@ -0,0 +1 @@
# Empty init file to make the directory a proper Python package

82
migrations/env.py Normal file
View File

@ -0,0 +1,82 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Import models for alembic
from app.db.base_class import Base # noqa: E402
# 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
# 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():
"""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
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,140 @@
"""Initial migration
Revision ID: 8e3ca9ec8ac8
Revises:
Create Date: 2023-08-07 12:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "8e3ca9ec8ac8"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Create users table
op.create_table(
"users",
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("is_superuser", sa.Boolean(), nullable=True, default=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
op.create_index(op.f("ix_users_full_name"), "users", ["full_name"], unique=False)
op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False)
# Create products table
op.create_table(
"products",
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.Float(), nullable=False),
sa.Column("stock", sa.Integer(), nullable=False, default=0),
sa.Column("image_url", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_products_id"), "products", ["id"], unique=False)
op.create_index(op.f("ix_products_name"), "products", ["name"], unique=False)
# Create orders table
op.create_table(
"orders",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column(
"status",
sa.Enum(
"pending",
"paid",
"shipped",
"delivered",
"cancelled",
name="orderstatus",
),
nullable=False,
default="pending",
),
sa.Column("total_amount", sa.Float(), nullable=False),
sa.Column("shipping_address", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_orders_id"), "orders", ["id"], unique=False)
# Create cart_items table
op.create_table(
"cart_items",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("product_id", sa.Integer(), nullable=False),
sa.Column("quantity", sa.Integer(), nullable=False, default=1),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
["product_id"],
["products.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_cart_items_id"), "cart_items", ["id"], unique=False)
# Create order_items table
op.create_table(
"order_items",
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=True),
sa.ForeignKeyConstraint(
["order_id"],
["orders.id"],
),
sa.ForeignKeyConstraint(
["product_id"],
["products.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_order_items_id"), "order_items", ["id"], unique=False)
def downgrade():
op.drop_index(op.f("ix_order_items_id"), table_name="order_items")
op.drop_table("order_items")
op.drop_index(op.f("ix_cart_items_id"), table_name="cart_items")
op.drop_table("cart_items")
op.drop_index(op.f("ix_orders_id"), table_name="orders")
op.drop_table("orders")
op.drop_index(op.f("ix_products_name"), table_name="products")
op.drop_index(op.f("ix_products_id"), table_name="products")
op.drop_table("products")
op.drop_index(op.f("ix_users_id"), table_name="users")
op.drop_index(op.f("ix_users_full_name"), table_name="users")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_table("users")

12
requirements.txt Normal file
View File

@ -0,0 +1,12 @@
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
python-multipart>=0.0.6
email-validator>=2.0.0
bcrypt>=4.0.1
ruff>=0.0.287