Implement ecommerce authentication and inventory API

- Set up project structure and FastAPI application
- Create database models for users, products, and inventory
- Configure SQLAlchemy and Alembic for database management
- Implement JWT authentication
- Create API endpoints for user, product, and inventory management
- Add admin-only routes and authorization middleware
- Add health check endpoint
- Update README with documentation
- Lint and fix code issues
This commit is contained in:
Automated Action 2025-06-08 21:40:55 +00:00
parent aef47858f1
commit b8143c43e4
41 changed files with 2907 additions and 2 deletions

160
README.md
View File

@ -1,3 +1,159 @@
# FastAPI Application # Ecommerce Authentication and Inventory API
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. A FastAPI backend that provides authentication, product management, and inventory tracking for an ecommerce application.
## Features
- User authentication with JWT tokens
- Role-based access control (admin, staff, customer)
- Product and category management
- Inventory tracking with transaction history
- Admin-only routes for sensitive operations
- SQLite database with SQLAlchemy ORM
- Database migrations with Alembic
## Project Structure
```
ecommerceauthenticationandinventoryapi/
├── app/
│ ├── api/
│ │ ├── endpoints/
│ │ │ ├── auth.py
│ │ │ ├── inventory.py
│ │ │ ├── products.py
│ │ │ └── users.py
│ │ ├── api.py
│ │ └── deps.py
│ ├── core/
│ │ ├── config.py
│ │ └── security.py
│ ├── crud/
│ │ ├── inventory.py
│ │ ├── product.py
│ │ └── user.py
│ ├── db/
│ │ ├── base.py
│ │ ├── deps.py
│ │ └── session.py
│ ├── models/
│ │ ├── inventory.py
│ │ ├── product.py
│ │ └── user.py
│ └── schemas/
│ ├── auth.py
│ ├── inventory.py
│ ├── product.py
│ ├── token.py
│ └── user.py
├── migrations/
│ └── versions/
│ └── 00001_initial_schema.py
├── alembic.ini
├── main.py
└── requirements.txt
```
## API Endpoints
### Authentication
- `POST /api/v1/auth/register` - Register a new user
- `POST /api/v1/auth/login` - Login with username/password (OAuth2 form)
- `POST /api/v1/auth/login/json` - Login with email/password (JSON)
- `GET /api/v1/auth/me` - Get current user details
### Users
- `GET /api/v1/users/` - Get all users (admin only)
- `POST /api/v1/users/` - Create a new user (admin only)
- `GET /api/v1/users/{user_id}` - Get user by ID
- `PUT /api/v1/users/{user_id}` - Update user
- `DELETE /api/v1/users/{user_id}` - Delete user (admin only)
### Products
- `GET /api/v1/products/` - Get all products
- `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 product (admin only)
- `DELETE /api/v1/products/{product_id}` - Delete product (admin only)
### Categories
- `GET /api/v1/products/categories/` - Get all categories
- `POST /api/v1/products/categories/` - Create a new category (admin only)
- `GET /api/v1/products/categories/{category_id}` - Get category by ID
- `PUT /api/v1/products/categories/{category_id}` - Update category (admin only)
- `DELETE /api/v1/products/categories/{category_id}` - Delete category (admin only)
### Inventory
- `GET /api/v1/inventory/items/` - Get all inventory items
- `POST /api/v1/inventory/items/` - Create a new inventory item (admin only)
- `GET /api/v1/inventory/items/{item_id}` - Get inventory item by ID
- `PUT /api/v1/inventory/items/{item_id}` - Update inventory item (admin only)
- `DELETE /api/v1/inventory/items/{item_id}` - Delete inventory item (admin only)
### Inventory Transactions
- `GET /api/v1/inventory/transactions/` - Get all transactions (admin only)
- `POST /api/v1/inventory/transactions/` - Create a new transaction (admin only)
- `GET /api/v1/inventory/transactions/{transaction_id}` - Get transaction by ID (admin only)
## Getting Started
### Prerequisites
- Python 3.8+
- SQLite
### Installation
1. Clone the repository
2. Install dependencies:
```
pip install -r requirements.txt
```
3. Set up environment variables (see below)
4. Run database migrations:
```
alembic upgrade head
```
5. Start the server:
```
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
```
### Environment Variables
Create a `.env` file in the root directory with the following variables:
```
SECRET_KEY=your-secret-key-here
ACCESS_TOKEN_EXPIRE_MINUTES=30
BACKEND_CORS_ORIGINS=["*"] # For production, specify allowed origins
```
## Documentation
API documentation is available at:
- Swagger UI: `/docs`
- ReDoc: `/redoc`
- OpenAPI JSON: `/openapi.json`
## Authentication
This API uses JWT tokens for authentication. To authenticate:
1. Register a user or login with existing credentials
2. Use the returned access token in the Authorization header for subsequent requests:
```
Authorization: Bearer <access_token>
```
## User Roles
- **Admin**: Full access to all endpoints
- **Staff**: Access to view products and inventory
- **Customer**: Limited access to view products

103
alembic.ini Normal file
View File

@ -0,0 +1,103 @@
# 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
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# SQLite URL for absolute path
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
app/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file makes the directory a Python package

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

14
app/api/api.py Normal file
View File

@ -0,0 +1,14 @@
from fastapi import APIRouter
from app.core.config import settings
# Import endpoints
from .endpoints import auth, inventory, products, users
# Create main API router
api_router = APIRouter(prefix=settings.API_V1_STR)
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(products.router, prefix="/products", tags=["products"])
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])

115
app/api/deps.py Normal file
View File

@ -0,0 +1,115 @@
from typing import Generator
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.core.config import settings
from app.db.session import SessionLocal
# OAuth2 token URL
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login"
)
def get_db() -> Generator:
"""
Get a database session.
Yields:
Generator: A database session.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_current_user(
db: Session = Depends(get_db),
token: str = Depends(oauth2_scheme)
) -> models.User:
"""
Get the current authenticated user.
Args:
db: Database session
token: JWT token
Returns:
User: The current user
Raises:
HTTPException: If authentication fails
"""
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
token_data = schemas.TokenPayload(**payload)
except (jwt.JWTError, ValidationError) as err:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
) from err
user = crud.user.get_by_id(db, user_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: models.User = Depends(get_current_user),
) -> models.User:
"""
Get the current active user.
Args:
current_user: Current user
Returns:
User: The current active user
Raises:
HTTPException: If the user is inactive
"""
if not crud.user.is_active(current_user):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
return current_user
def get_current_admin_user(
current_user: models.User = Depends(get_current_user),
) -> models.User:
"""
Get the current admin user.
Args:
current_user: Current user
Returns:
User: The current admin user
Raises:
HTTPException: If the user is not an admin
"""
if not crud.user.is_admin(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user

View File

@ -0,0 +1 @@
# This file makes the directory a Python package

120
app/api/endpoints/auth.py Normal file
View File

@ -0,0 +1,120 @@
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 import crud
from app.api import deps
from app.core import security
from app.core.config import settings
from app.schemas.auth import Login, TokenResponse
from app.schemas.user import User, UserCreate
router = APIRouter()
@router.post("/login", response_model=TokenResponse)
def login(
db: Session = Depends(deps.get_db),
form_data: OAuth2PasswordRequestForm = Depends(),
) -> Any:
"""
Get an access token for future requests using OAuth2 compatible form.
"""
user = crud.user.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"},
)
if not crud.user.is_active(user):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user",
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = security.create_access_token(
user.id, expires_delta=access_token_expires
)
return {
"access_token": access_token,
"token_type": "bearer",
}
@router.post("/login/json", response_model=TokenResponse)
def login_json(
login_data: Login,
db: Session = Depends(deps.get_db),
) -> Any:
"""
Get an access token for future requests using JSON body.
"""
user = crud.user.authenticate(
db, email=login_data.email, password=login_data.password
)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not crud.user.is_active(user):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user",
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = security.create_access_token(
user.id, expires_delta=access_token_expires
)
return {
"access_token": access_token,
"token_type": "bearer",
}
@router.post("/register", response_model=User)
def register(
user_in: UserCreate,
db: Session = Depends(deps.get_db),
) -> Any:
"""
Register a new user.
"""
user = crud.user.get_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
user = crud.user.get_by_username(db, username=user_in.username)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered",
)
user = crud.user.create(db, obj_in=user_in)
return user
@router.get("/me", response_model=User)
def read_users_me(
current_user: User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get the current user.
"""
return current_user

View File

@ -0,0 +1,226 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
from app.models.inventory import InventoryStatus
router = APIRouter()
# Inventory Item endpoints
@router.get("/items/", response_model=List[schemas.InventoryItemWithProduct])
def read_inventory_items(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
status: Optional[InventoryStatus] = None,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve inventory items with optional filtering.
"""
items = crud.inventory.get_all_inventory_items(
db,
skip=skip,
limit=limit,
status=status
)
return items
@router.post("/items/", response_model=schemas.InventoryItem)
def create_inventory_item(
*,
db: Session = Depends(deps.get_db),
item_in: schemas.InventoryItemCreate,
current_user: models.User = Depends(deps.get_current_admin_user),
) -> Any:
"""
Create new inventory item. Admin only.
"""
# Check if product exists
product = crud.product.get_product_by_id(db, product_id=item_in.product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)
# Check if inventory item for this product already exists
existing_item = crud.inventory.get_inventory_item_by_product_id(db, product_id=item_in.product_id)
if existing_item:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inventory item for this product already exists",
)
# Create inventory item
item = crud.inventory.create_inventory_item(db, obj_in=item_in)
# Create initial inventory transaction
if item_in.quantity > 0:
transaction_in = schemas.InventoryTransactionCreate(
product_id=item_in.product_id,
quantity_change=item_in.quantity,
notes="Initial inventory",
transaction_by=current_user.id
)
crud.inventory.create_transaction(db, obj_in=transaction_in)
return item
@router.get("/items/{item_id}", response_model=schemas.InventoryItemWithProduct)
def read_inventory_item(
*,
db: Session = Depends(deps.get_db),
item_id: str,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get inventory item by ID.
"""
item = crud.inventory.get_inventory_item_by_id(db, item_id=item_id)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Inventory item not found",
)
return item
@router.put("/items/{item_id}", response_model=schemas.InventoryItem)
def update_inventory_item(
*,
db: Session = Depends(deps.get_db),
item_id: str,
item_in: schemas.InventoryItemUpdate,
current_user: models.User = Depends(deps.get_current_admin_user),
) -> Any:
"""
Update an inventory item. Admin only.
"""
item = crud.inventory.get_inventory_item_by_id(db, item_id=item_id)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Inventory item not found",
)
# Create transaction if quantity is being updated
if item_in.quantity is not None and item_in.quantity != item.quantity:
quantity_change = item_in.quantity - item.quantity
transaction_in = schemas.InventoryTransactionCreate(
product_id=item.product_id,
quantity_change=quantity_change,
notes=f"Manual inventory adjustment from {item.quantity} to {item_in.quantity}",
transaction_by=current_user.id
)
crud.inventory.create_transaction(db, obj_in=transaction_in)
item = crud.inventory.update_inventory_item(db, db_obj=item, obj_in=item_in)
return item
@router.delete("/items/{item_id}", response_model=schemas.InventoryItem, status_code=status.HTTP_200_OK)
def delete_inventory_item(
*,
db: Session = Depends(deps.get_db),
item_id: str,
current_user: models.User = Depends(deps.get_current_admin_user),
) -> Any:
"""
Delete an inventory item. Admin only.
"""
item = crud.inventory.get_inventory_item_by_id(db, item_id=item_id)
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Inventory item not found",
)
item = crud.inventory.delete_inventory_item(db, item_id=item_id)
return item
# Inventory Transaction endpoints
@router.get("/transactions/", response_model=List[schemas.InventoryTransaction])
def read_transactions(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
product_id: Optional[str] = None,
current_user: models.User = Depends(deps.get_current_admin_user),
) -> Any:
"""
Retrieve inventory transactions. Admin only.
"""
if product_id:
transactions = crud.inventory.get_transactions_by_product_id(
db,
product_id=product_id,
skip=skip,
limit=limit
)
else:
# Get all transactions - would need to add a method for this
# For now, we'll just return an empty list
transactions = []
return transactions
@router.post("/transactions/", response_model=schemas.InventoryTransaction)
def create_transaction(
*,
db: Session = Depends(deps.get_db),
transaction_in: schemas.InventoryTransactionCreate,
current_user: models.User = Depends(deps.get_current_admin_user),
) -> Any:
"""
Create new inventory transaction. Admin only.
"""
# Check if product exists
product = crud.product.get_product_by_id(db, product_id=transaction_in.product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)
# Check if inventory item exists for this product
inventory_item = crud.inventory.get_inventory_item_by_product_id(db, product_id=transaction_in.product_id)
if not inventory_item:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No inventory item found for this product",
)
# Set the current user as the transaction creator if not specified
if transaction_in.transaction_by is None:
transaction_in.transaction_by = current_user.id
transaction = crud.inventory.create_transaction(db, obj_in=transaction_in)
return transaction
@router.get("/transactions/{transaction_id}", response_model=schemas.InventoryTransaction)
def read_transaction(
*,
db: Session = Depends(deps.get_db),
transaction_id: str,
current_user: models.User = Depends(deps.get_current_admin_user),
) -> Any:
"""
Get transaction by ID. Admin only.
"""
transaction = crud.inventory.get_transaction_by_id(db, transaction_id=transaction_id)
if not transaction:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Transaction not found",
)
return transaction

View File

@ -0,0 +1,266 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
router = APIRouter()
# Category endpoints
@router.get("/categories/", response_model=List[schemas.Category])
def read_categories(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve categories.
"""
categories = crud.product.get_all_categories(db, skip=skip, limit=limit)
return categories
@router.post("/categories/", response_model=schemas.Category)
def create_category(
*,
db: Session = Depends(deps.get_db),
category_in: schemas.CategoryCreate,
current_user: models.User = Depends(deps.get_current_admin_user),
) -> Any:
"""
Create new category. Admin only.
"""
category = crud.product.get_category_by_name(db, name=category_in.name)
if category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Category with this name already exists",
)
category = crud.product.create_category(db, obj_in=category_in)
return category
@router.get("/categories/{category_id}", response_model=schemas.Category)
def read_category(
*,
db: Session = Depends(deps.get_db),
category_id: str,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get category by ID.
"""
category = crud.product.get_category_by_id(db, category_id=category_id)
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found",
)
return category
@router.put("/categories/{category_id}", response_model=schemas.Category)
def update_category(
*,
db: Session = Depends(deps.get_db),
category_id: str,
category_in: schemas.CategoryUpdate,
current_user: models.User = Depends(deps.get_current_admin_user),
) -> Any:
"""
Update a category. Admin only.
"""
category = crud.product.get_category_by_id(db, category_id=category_id)
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found",
)
# Check if updated name already exists
if category_in.name and category_in.name != category.name:
existing_category = crud.product.get_category_by_name(db, name=category_in.name)
if existing_category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Category with this name already exists",
)
category = crud.product.update_category(db, db_obj=category, obj_in=category_in)
return category
@router.delete("/categories/{category_id}", response_model=schemas.Category)
def delete_category(
*,
db: Session = Depends(deps.get_db),
category_id: str,
current_user: models.User = Depends(deps.get_current_admin_user),
) -> Any:
"""
Delete a category. Admin only.
"""
category = crud.product.get_category_by_id(db, category_id=category_id)
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found",
)
# Check if category has products
products = crud.product.get_all_products(db, category_id=category_id)
if products:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete category with associated products",
)
category = crud.product.delete_category(db, category_id=category_id)
return category
# Product endpoints
@router.get("/", response_model=List[schemas.Product])
def read_products(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
category_id: Optional[str] = None,
active_only: bool = False,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve products with optional filtering.
"""
products = crud.product.get_all_products(
db,
skip=skip,
limit=limit,
category_id=category_id,
active_only=active_only
)
return products
@router.post("/", response_model=schemas.Product)
def create_product(
*,
db: Session = Depends(deps.get_db),
product_in: schemas.ProductCreate,
current_user: models.User = Depends(deps.get_current_admin_user),
) -> Any:
"""
Create new product. Admin only.
"""
# Check if SKU exists
product = crud.product.get_product_by_sku(db, sku=product_in.sku)
if product:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Product with this SKU already exists",
)
# Check if category exists if provided
if product_in.category_id:
category = crud.product.get_category_by_id(db, category_id=product_in.category_id)
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found",
)
product = crud.product.create_product(db, obj_in=product_in)
return product
@router.get("/{product_id}", response_model=schemas.ProductWithCategory)
def read_product(
*,
db: Session = Depends(deps.get_db),
product_id: str,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get product by ID.
"""
product = crud.product.get_product_by_id(db, product_id=product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)
return product
@router.put("/{product_id}", response_model=schemas.Product)
def update_product(
*,
db: Session = Depends(deps.get_db),
product_id: str,
product_in: schemas.ProductUpdate,
current_user: models.User = Depends(deps.get_current_admin_user),
) -> Any:
"""
Update a product. Admin only.
"""
product = crud.product.get_product_by_id(db, product_id=product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)
# Check if updated SKU already exists
if product_in.sku and product_in.sku != product.sku:
existing_product = crud.product.get_product_by_sku(db, sku=product_in.sku)
if existing_product:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Product with this SKU already exists",
)
# Check if category exists if provided
if product_in.category_id:
category = crud.product.get_category_by_id(db, category_id=product_in.category_id)
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found",
)
product = crud.product.update_product(db, db_obj=product, obj_in=product_in)
return product
@router.delete("/{product_id}", response_model=schemas.Product)
def delete_product(
*,
db: Session = Depends(deps.get_db),
product_id: str,
current_user: models.User = Depends(deps.get_current_admin_user),
) -> Any:
"""
Delete a product. Admin only.
"""
product = crud.product.get_product_by_id(db, product_id=product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)
# Check if product has inventory items
inventory_item = crud.inventory.get_inventory_item_by_product_id(db, product_id=product_id)
if inventory_item:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete product with associated inventory items",
)
product = crud.product.delete_product(db, product_id=product_id)
return product

158
app/api/endpoints/users.py Normal file
View File

@ -0,0 +1,158 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
router = APIRouter()
@router.get("/", response_model=List[schemas.User])
def read_users(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_admin_user),
) -> Any:
"""
Retrieve users. Admin only.
"""
users = crud.user.get_all(db, skip=skip, limit=limit)
return users
@router.post("/", response_model=schemas.User)
def create_user(
*,
db: Session = Depends(deps.get_db),
user_in: schemas.UserCreate,
current_user: models.User = Depends(deps.get_current_admin_user),
) -> Any:
"""
Create new user. Admin only.
"""
user = crud.user.get_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
user = crud.user.get_by_username(db, username=user_in.username)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered",
)
user = crud.user.create(db, obj_in=user_in)
return user
@router.get("/{user_id}", response_model=schemas.User)
def read_user(
user_id: str,
current_user: models.User = Depends(deps.get_current_active_user),
db: Session = Depends(deps.get_db),
) -> Any:
"""
Get a specific user by id.
"""
user = crud.user.get_by_id(db, user_id=user_id)
if user == current_user:
return user
if not crud.user.is_admin(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to access this resource",
)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return user
@router.put("/{user_id}", response_model=schemas.User)
def update_user(
*,
db: Session = Depends(deps.get_db),
user_id: str,
user_in: schemas.UserUpdate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Update a user.
"""
user = crud.user.get_by_id(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Check permissions
if user.id != current_user.id and not crud.user.is_admin(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to perform this action",
)
# Prevent non-admin users from changing their role
if user_in.role is not None and user_in.role != user.role and not crud.user.is_admin(current_user):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to change the role",
)
# If updating email, check it's not already taken
if user_in.email is not None and user_in.email != user.email:
existing_user = crud.user.get_by_email(db, email=user_in.email)
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# If updating username, check it's not already taken
if user_in.username is not None and user_in.username != user.username:
existing_user = crud.user.get_by_username(db, username=user_in.username)
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered",
)
user = crud.user.update(db, db_obj=user, obj_in=user_in)
return user
@router.delete("/{user_id}", response_model=schemas.User)
def delete_user(
*,
db: Session = Depends(deps.get_db),
user_id: str,
current_user: models.User = Depends(deps.get_current_admin_user),
) -> Any:
"""
Delete a user. Admin only.
"""
user = crud.user.get_by_id(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Prevent admins from deleting themselves
if user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Users cannot delete their own account",
)
user = crud.user.delete(db, user_id=user_id)
return user

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

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

@ -0,0 +1,43 @@
import secrets
from pathlib import Path
from typing import List, Optional
from pydantic import validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings."""
# API
API_V1_STR: str = "/api/v1"
PROJECT_NAME: str = "Ecommerce Authentication and Inventory API"
# Security
SECRET_KEY: str = secrets.token_urlsafe(32)
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# CORS
BACKEND_CORS_ORIGINS: List[str] = ["*"]
# Database
DB_DIR: Path = Path("/app") / "storage" / "db"
SQLALCHEMY_DATABASE_URL: Optional[str] = None
@validator("SQLALCHEMY_DATABASE_URL", pre=True)
def assemble_db_url(cls, v: Optional[str], values: dict) -> str:
if v:
return v
db_dir = values.get("DB_DIR")
db_dir.mkdir(parents=True, exist_ok=True)
return f"sqlite:///{db_dir}/db.sqlite"
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()

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

@ -0,0 +1,65 @@
from datetime import datetime, timedelta
from typing import Any, Optional, Union
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def create_access_token(
subject: Union[str, Any], expires_delta: Optional[timedelta] = None
) -> str:
"""
Create a JWT access token.
Args:
subject: The subject of the token (usually user ID)
expires_delta: Token expiration time
Returns:
str: 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=settings.ALGORITHM
)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a password against a hash.
Args:
plain_password: Plain password
hashed_password: Hashed password
Returns:
bool: True if the password matches the hash
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Get the hash of a password.
Args:
password: Plain password
Returns:
str: Hashed password
"""
return pwd_context.hash(password)

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

@ -0,0 +1,6 @@
# flake8: noqa
from . import inventory, product, user
# Make all CRUD modules available in this module
__all__ = ["inventory", "product", "user"]

228
app/crud/inventory.py Normal file
View File

@ -0,0 +1,228 @@
from typing import Any, Dict, List, Optional, Union
from sqlalchemy.orm import Session
from app.models.inventory import InventoryItem, InventoryStatus, InventoryTransaction
from app.schemas.inventory import (
InventoryItemCreate,
InventoryItemUpdate,
InventoryTransactionCreate,
)
# Inventory Item CRUD operations
def get_inventory_item_by_id(db: Session, item_id: str) -> Optional[InventoryItem]:
"""
Get an inventory item by ID.
Args:
db: Database session
item_id: Inventory item ID
Returns:
Optional[InventoryItem]: Inventory item if found, None otherwise
"""
return db.query(InventoryItem).filter(InventoryItem.id == item_id).first()
def get_inventory_item_by_product_id(db: Session, product_id: str) -> Optional[InventoryItem]:
"""
Get an inventory item by product ID.
Args:
db: Database session
product_id: Product ID
Returns:
Optional[InventoryItem]: Inventory item if found, None otherwise
"""
return db.query(InventoryItem).filter(InventoryItem.product_id == product_id).first()
def get_all_inventory_items(
db: Session,
skip: int = 0,
limit: int = 100,
status: Optional[InventoryStatus] = None
) -> List[InventoryItem]:
"""
Get all inventory items with optional filtering.
Args:
db: Database session
skip: Number of items to skip
limit: Maximum number of items to return
status: Optional inventory status filter
Returns:
List[InventoryItem]: List of inventory items
"""
query = db.query(InventoryItem)
if status:
query = query.filter(InventoryItem.status == status)
return query.offset(skip).limit(limit).all()
def create_inventory_item(db: Session, *, obj_in: InventoryItemCreate) -> InventoryItem:
"""
Create a new inventory item.
Args:
db: Database session
obj_in: Inventory item creation data
Returns:
InventoryItem: Created inventory item
"""
db_obj = InventoryItem(
product_id=obj_in.product_id,
quantity=obj_in.quantity,
status=obj_in.status,
location=obj_in.location,
notes=obj_in.notes,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update_inventory_item(
db: Session,
*,
db_obj: InventoryItem,
obj_in: Union[InventoryItemUpdate, Dict[str, Any]]
) -> InventoryItem:
"""
Update an inventory item.
Args:
db: Database session
db_obj: Inventory item to update
obj_in: Inventory item update data
Returns:
InventoryItem: Updated inventory item
"""
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
for field in update_data:
if hasattr(db_obj, field):
setattr(db_obj, field, update_data[field])
# Auto-update status based on quantity
if "quantity" in update_data:
if db_obj.quantity <= 0:
db_obj.status = InventoryStatus.OUT_OF_STOCK
elif db_obj.quantity < 10: # Arbitrary threshold for low stock
db_obj.status = InventoryStatus.LOW_STOCK
else:
db_obj.status = InventoryStatus.IN_STOCK
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete_inventory_item(db: Session, *, item_id: str) -> InventoryItem:
"""
Delete an inventory item.
Args:
db: Database session
item_id: Inventory item ID
Returns:
InventoryItem: Deleted inventory item
"""
item = db.query(InventoryItem).filter(InventoryItem.id == item_id).first()
db.delete(item)
db.commit()
return item
# Inventory Transaction CRUD operations
def get_transaction_by_id(db: Session, transaction_id: str) -> Optional[InventoryTransaction]:
"""
Get an inventory transaction by ID.
Args:
db: Database session
transaction_id: Transaction ID
Returns:
Optional[InventoryTransaction]: Transaction if found, None otherwise
"""
return db.query(InventoryTransaction).filter(InventoryTransaction.id == transaction_id).first()
def get_transactions_by_product_id(
db: Session,
product_id: str,
skip: int = 0,
limit: int = 100
) -> List[InventoryTransaction]:
"""
Get inventory transactions by product ID.
Args:
db: Database session
product_id: Product ID
skip: Number of transactions to skip
limit: Maximum number of transactions to return
Returns:
List[InventoryTransaction]: List of inventory transactions
"""
return db.query(InventoryTransaction).filter(
InventoryTransaction.product_id == product_id
).order_by(InventoryTransaction.created_at.desc()).offset(skip).limit(limit).all()
def create_transaction(
db: Session,
*,
obj_in: InventoryTransactionCreate
) -> InventoryTransaction:
"""
Create a new inventory transaction.
Args:
db: Database session
obj_in: Transaction creation data
Returns:
InventoryTransaction: Created transaction
"""
db_obj = InventoryTransaction(
product_id=obj_in.product_id,
quantity_change=obj_in.quantity_change,
notes=obj_in.notes,
transaction_by=obj_in.transaction_by,
)
db.add(db_obj)
# Update inventory item quantity
inventory_item = get_inventory_item_by_product_id(db, product_id=obj_in.product_id)
if inventory_item:
inventory_item.quantity += obj_in.quantity_change
# Update status based on new quantity
if inventory_item.quantity <= 0:
inventory_item.status = InventoryStatus.OUT_OF_STOCK
elif inventory_item.quantity < 10: # Arbitrary threshold for low stock
inventory_item.status = InventoryStatus.LOW_STOCK
else:
inventory_item.status = InventoryStatus.IN_STOCK
db.add(inventory_item)
db.commit()
db.refresh(db_obj)
return db_obj

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

@ -0,0 +1,259 @@
from typing import Any, Dict, List, Optional, Union
from sqlalchemy.orm import Session
from app.models.product import Category, Product
from app.schemas.product import (
CategoryCreate,
CategoryUpdate,
ProductCreate,
ProductUpdate,
)
# Product CRUD operations
def get_product_by_id(db: Session, product_id: str) -> Optional[Product]:
"""
Get a product by ID.
Args:
db: Database session
product_id: Product ID
Returns:
Optional[Product]: Product if found, None otherwise
"""
return db.query(Product).filter(Product.id == product_id).first()
def get_product_by_sku(db: Session, sku: str) -> Optional[Product]:
"""
Get a product by SKU.
Args:
db: Database session
sku: Product SKU
Returns:
Optional[Product]: Product if found, None otherwise
"""
return db.query(Product).filter(Product.sku == sku).first()
def get_all_products(
db: Session,
skip: int = 0,
limit: int = 100,
category_id: Optional[str] = None,
active_only: bool = False
) -> List[Product]:
"""
Get all products with optional filtering.
Args:
db: Database session
skip: Number of products to skip
limit: Maximum number of products to return
category_id: Optional category ID filter
active_only: Only include active products
Returns:
List[Product]: List of products
"""
query = db.query(Product)
if category_id:
query = query.filter(Product.category_id == category_id)
if active_only:
query = query.filter(Product.is_active.is_(True))
return query.offset(skip).limit(limit).all()
def create_product(db: Session, *, obj_in: ProductCreate) -> Product:
"""
Create a new product.
Args:
db: Database session
obj_in: Product creation data
Returns:
Product: Created product
"""
db_obj = Product(
name=obj_in.name,
description=obj_in.description,
price=obj_in.price,
sku=obj_in.sku,
is_active=obj_in.is_active,
category_id=obj_in.category_id,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update_product(
db: Session,
*,
db_obj: Product,
obj_in: Union[ProductUpdate, Dict[str, Any]]
) -> Product:
"""
Update a product.
Args:
db: Database session
db_obj: Product to update
obj_in: Product update data
Returns:
Product: Updated product
"""
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
for field in update_data:
if hasattr(db_obj, field):
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete_product(db: Session, *, product_id: str) -> Product:
"""
Delete a product.
Args:
db: Database session
product_id: Product ID
Returns:
Product: Deleted product
"""
product = db.query(Product).filter(Product.id == product_id).first()
db.delete(product)
db.commit()
return product
# Category CRUD operations
def get_category_by_id(db: Session, category_id: str) -> Optional[Category]:
"""
Get a category by ID.
Args:
db: Database session
category_id: Category ID
Returns:
Optional[Category]: Category if found, None otherwise
"""
return db.query(Category).filter(Category.id == category_id).first()
def get_category_by_name(db: Session, name: str) -> Optional[Category]:
"""
Get a category by name.
Args:
db: Database session
name: Category name
Returns:
Optional[Category]: Category if found, None otherwise
"""
return db.query(Category).filter(Category.name == name).first()
def get_all_categories(db: Session, skip: int = 0, limit: int = 100) -> List[Category]:
"""
Get all categories.
Args:
db: Database session
skip: Number of categories to skip
limit: Maximum number of categories to return
Returns:
List[Category]: List of categories
"""
return db.query(Category).offset(skip).limit(limit).all()
def create_category(db: Session, *, obj_in: CategoryCreate) -> Category:
"""
Create a new category.
Args:
db: Database session
obj_in: Category creation data
Returns:
Category: Created category
"""
db_obj = Category(
name=obj_in.name,
description=obj_in.description,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update_category(
db: Session,
*,
db_obj: Category,
obj_in: Union[CategoryUpdate, Dict[str, Any]]
) -> Category:
"""
Update a category.
Args:
db: Database session
db_obj: Category to update
obj_in: Category update data
Returns:
Category: Updated category
"""
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
for field in update_data:
if hasattr(db_obj, field):
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete_category(db: Session, *, category_id: str) -> Category:
"""
Delete a category.
Args:
db: Database session
category_id: Category ID
Returns:
Category: Deleted category
"""
category = db.query(Category).filter(Category.id == category_id).first()
db.delete(category)
db.commit()
return category

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

@ -0,0 +1,186 @@
from typing import Any, Dict, List, Optional, Union
from sqlalchemy.orm import Session
from app.core.security import get_password_hash, verify_password
from app.models.user import User, UserRole
from app.schemas.user import UserCreate, UserUpdate
def get_by_id(db: Session, user_id: str) -> Optional[User]:
"""
Get a user by ID.
Args:
db: Database session
user_id: User ID
Returns:
Optional[User]: User if found, None otherwise
"""
return db.query(User).filter(User.id == user_id).first()
def get_by_email(db: Session, email: str) -> Optional[User]:
"""
Get a user by email.
Args:
db: Database session
email: User email
Returns:
Optional[User]: User if found, None otherwise
"""
return db.query(User).filter(User.email == email).first()
def get_by_username(db: Session, username: str) -> Optional[User]:
"""
Get a user by username.
Args:
db: Database session
username: Username
Returns:
Optional[User]: User if found, None otherwise
"""
return db.query(User).filter(User.username == username).first()
def get_all(db: Session, skip: int = 0, limit: int = 100) -> List[User]:
"""
Get all users.
Args:
db: Database session
skip: Number of users to skip
limit: Maximum number of users to return
Returns:
List[User]: List of users
"""
return db.query(User).offset(skip).limit(limit).all()
def create(db: Session, *, obj_in: UserCreate) -> User:
"""
Create a new user.
Args:
db: Database session
obj_in: User creation data
Returns:
User: Created user
"""
db_obj = User(
email=obj_in.email,
username=obj_in.username,
hashed_password=get_password_hash(obj_in.password),
full_name=obj_in.full_name,
role=obj_in.role,
is_active=True,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]
) -> User:
"""
Update a user.
Args:
db: Database session
db_obj: User to update
obj_in: User update data
Returns:
User: Updated user
"""
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
if update_data.get("password"):
hashed_password = get_password_hash(update_data["password"])
del update_data["password"]
update_data["hashed_password"] = hashed_password
for field in update_data:
if hasattr(db_obj, field):
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def delete(db: Session, *, user_id: str) -> User:
"""
Delete a user.
Args:
db: Database session
user_id: User ID
Returns:
User: Deleted user
"""
user = db.query(User).filter(User.id == user_id).first()
db.delete(user)
db.commit()
return user
def authenticate(db: Session, *, email: str, password: str) -> Optional[User]:
"""
Authenticate a user.
Args:
db: Database session
email: User email
password: User password
Returns:
Optional[User]: User if authentication successful, None otherwise
"""
user = 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(user: User) -> bool:
"""
Check if a user is active.
Args:
user: User to check
Returns:
bool: True if the user is active
"""
return user.is_active
def is_admin(user: User) -> bool:
"""
Check if a user is an admin.
Args:
user: User to check
Returns:
bool: True if the user is an admin
"""
return user.role == UserRole.ADMIN

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

@ -0,0 +1 @@
# This file makes the directory a Python package

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

@ -0,0 +1,4 @@
from sqlalchemy.ext.declarative import declarative_base
# Base class for all SQLAlchemy models
Base = declarative_base()

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

@ -0,0 +1,16 @@
from typing import Any
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import as_declarative
@as_declarative()
class Base:
"""Base class for all SQLAlchemy models."""
id: Any
__name__: str
# Generate __tablename__ automatically
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower()

121
app/db/deps.py Normal file
View File

@ -0,0 +1,121 @@
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.db.session import SessionLocal
from app.models.user import User
from app.schemas.token import TokenPayload
# OAuth2 token URL
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login"
)
def get_db() -> Generator:
"""
Get a database session.
Yields:
Generator: A database session.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_current_user(
db: Session = Depends(get_db),
token: str = Depends(oauth2_scheme)
) -> User:
"""
Get the current authenticated user.
Args:
db: Database session
token: JWT token
Returns:
User: The current user
Raises:
HTTPException: If authentication fails
"""
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
token_data = TokenPayload(**payload)
except (JWTError, ValidationError) as err:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
) from err
user = db.query(User).filter(User.id == token_data.sub).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
return user
def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Get the current active user.
Args:
current_user: Current user
Returns:
User: The current active user
Raises:
HTTPException: If the user is inactive
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
return current_user
def get_current_admin_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Get the current admin user.
Args:
current_user: Current user
Returns:
User: The current admin user
Raises:
HTTPException: If the user is not an admin
"""
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user

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

@ -0,0 +1,25 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# Create SQLite engine with check_same_thread=False for SQLite
engine = create_engine(
settings.SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}
)
# Create SessionLocal class for database sessions
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
"""
Dependency for FastAPI endpoints that need a database session.
Yields a database session and ensures it is closed after use.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

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

@ -0,0 +1,18 @@
# Import all models here for Alembic to detect
# These imports are needed for Alembic to detect the models
# flake8: noqa
from app.models.inventory import InventoryItem, InventoryStatus, InventoryTransaction
from app.models.product import Category, Product
from app.models.user import User, UserRole
# Make all models available in this module
__all__ = [
"InventoryItem",
"InventoryStatus",
"InventoryTransaction",
"Category",
"Product",
"User",
"UserRole"
]

59
app/models/inventory.py Normal file
View File

@ -0,0 +1,59 @@
import enum
import uuid
from sqlalchemy import (
Column,
DateTime,
Enum,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base import Base
class InventoryStatus(str, enum.Enum):
"""Enum for inventory status."""
IN_STOCK = "in_stock"
LOW_STOCK = "low_stock"
OUT_OF_STOCK = "out_of_stock"
DISCONTINUED = "discontinued"
class InventoryItem(Base):
"""Inventory item model."""
__tablename__ = "inventory_items"
id = Column(String, primary_key=True, index=True, default=lambda: str(uuid.uuid4()))
quantity = Column(Integer, default=0, nullable=False)
status = Column(Enum(InventoryStatus), default=InventoryStatus.OUT_OF_STOCK, nullable=False)
location = Column(String, nullable=True)
notes = Column(Text, nullable=True)
# Foreign keys
product_id = Column(String, ForeignKey("products.id"), nullable=False)
# Relationships
product = relationship("Product", back_populates="inventory_items")
# Audit timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
class InventoryTransaction(Base):
"""Inventory transaction model for tracking inventory changes."""
__tablename__ = "inventory_transactions"
id = Column(String, primary_key=True, index=True, default=lambda: str(uuid.uuid4()))
product_id = Column(String, ForeignKey("products.id"), nullable=False)
quantity_change = Column(Integer, nullable=False) # Positive for additions, negative for subtractions
notes = Column(Text, nullable=True)
transaction_by = Column(String, ForeignKey("users.id"), nullable=True)
# Audit timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)

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

@ -0,0 +1,54 @@
import uuid
from sqlalchemy import (
Boolean,
Column,
DateTime,
Float,
ForeignKey,
String,
Text,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base import Base
class Category(Base):
"""Category model for product categorization."""
__tablename__ = "categories"
id = Column(String, primary_key=True, index=True, default=lambda: str(uuid.uuid4()))
name = Column(String, unique=True, index=True, nullable=False)
description = Column(Text, nullable=True)
# Relationships
products = relationship("Product", back_populates="category")
# Audit timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
class Product(Base):
"""Product model."""
__tablename__ = "products"
id = Column(String, primary_key=True, index=True, default=lambda: str(uuid.uuid4()))
name = Column(String, index=True, nullable=False)
description = Column(Text, nullable=True)
price = Column(Float, nullable=False)
sku = Column(String, unique=True, index=True, nullable=False)
is_active = Column(Boolean, default=True)
# Foreign keys
category_id = Column(String, ForeignKey("categories.id"), nullable=True)
# Relationships
category = relationship("Category", back_populates="products")
inventory_items = relationship("InventoryItem", back_populates="product")
# Audit timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

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

@ -0,0 +1,31 @@
import enum
import uuid
from sqlalchemy import Boolean, Column, DateTime, Enum, String
from sqlalchemy.sql import func
from app.db.base import Base
class UserRole(str, enum.Enum):
"""Enum for user roles."""
ADMIN = "admin"
CUSTOMER = "customer"
STAFF = "staff"
class User(Base):
"""User model."""
__tablename__ = "users"
id = Column(String, primary_key=True, index=True, default=lambda: str(uuid.uuid4()))
email = Column(String, unique=True, index=True, nullable=False)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String, nullable=True)
role = Column(Enum(UserRole), default=UserRole.CUSTOMER, nullable=False)
is_active = Column(Boolean, default=True)
# Audit timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

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

@ -0,0 +1,31 @@
# flake8: noqa
from .auth import Login, TokenResponse
from .inventory import (
InventoryItem,
InventoryItemCreate,
InventoryItemUpdate,
InventoryTransaction,
InventoryTransactionCreate,
)
from .product import (
Category,
CategoryCreate,
CategoryUpdate,
Product,
ProductCreate,
ProductUpdate,
)
from .token import Token, TokenPayload
from .user import User, UserBase, UserCreate, UserInDB, UserUpdate
# Make all schemas available in this module
__all__ = [
"Login", "TokenResponse",
"InventoryItem", "InventoryItemCreate", "InventoryItemUpdate",
"InventoryTransaction", "InventoryTransactionCreate",
"Category", "CategoryCreate", "CategoryUpdate",
"Product", "ProductCreate", "ProductUpdate",
"Token", "TokenPayload",
"User", "UserBase", "UserCreate", "UserInDB", "UserUpdate"
]

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

@ -0,0 +1,13 @@
from pydantic import BaseModel, EmailStr
class Login(BaseModel):
"""Login schema."""
email: EmailStr
password: str
class TokenResponse(BaseModel):
"""Token response schema."""
access_token: str
token_type: str

74
app/schemas/inventory.py Normal file
View File

@ -0,0 +1,74 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
from app.models.inventory import InventoryStatus
from app.schemas.product import Product
# Inventory item schemas
class InventoryItemBase(BaseModel):
"""Base inventory item schema."""
product_id: str
quantity: int = Field(..., ge=0)
status: InventoryStatus = InventoryStatus.OUT_OF_STOCK
location: Optional[str] = None
notes: Optional[str] = None
class InventoryItemCreate(InventoryItemBase):
"""Inventory item creation schema."""
pass
class InventoryItemUpdate(BaseModel):
"""Inventory item update schema."""
quantity: Optional[int] = Field(None, ge=0)
status: Optional[InventoryStatus] = None
location: Optional[str] = None
notes: Optional[str] = None
class InventoryItem(InventoryItemBase):
"""Inventory item schema to return to client."""
id: str
created_at: datetime
updated_at: datetime
class Config:
"""Configuration for the schema."""
from_attributes = True
class InventoryItemWithProduct(InventoryItem):
"""Inventory item schema with product to return to client."""
product: Product
class Config:
"""Configuration for the schema."""
from_attributes = True
# Inventory transaction schemas
class InventoryTransactionBase(BaseModel):
"""Base inventory transaction schema."""
product_id: str
quantity_change: int
notes: Optional[str] = None
transaction_by: Optional[str] = None
class InventoryTransactionCreate(InventoryTransactionBase):
"""Inventory transaction creation schema."""
pass
class InventoryTransaction(InventoryTransactionBase):
"""Inventory transaction schema to return to client."""
id: str
created_at: datetime
class Config:
"""Configuration for the schema."""
from_attributes = True

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

@ -0,0 +1,82 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
# Category schemas
class CategoryBase(BaseModel):
"""Base category schema."""
name: str
description: Optional[str] = None
class CategoryCreate(CategoryBase):
"""Category creation schema."""
pass
class CategoryUpdate(BaseModel):
"""Category update schema."""
name: Optional[str] = None
description: Optional[str] = None
class Category(CategoryBase):
"""Category schema to return to client."""
id: str
created_at: datetime
updated_at: datetime
class Config:
"""Configuration for the schema."""
from_attributes = True
# Product schemas
class ProductBase(BaseModel):
"""Base product schema."""
name: str
description: Optional[str] = None
price: float = Field(..., gt=0)
sku: str
is_active: bool = True
category_id: Optional[str] = None
class ProductCreate(ProductBase):
"""Product creation schema."""
pass
class ProductUpdate(BaseModel):
"""Product update schema."""
name: Optional[str] = None
description: Optional[str] = None
price: Optional[float] = Field(None, gt=0)
sku: Optional[str] = None
is_active: Optional[bool] = None
category_id: Optional[str] = None
class ProductWithCategory(ProductBase):
"""Product schema with category to return to client."""
id: str
created_at: datetime
updated_at: datetime
category: Optional[Category] = None
class Config:
"""Configuration for the schema."""
from_attributes = True
class Product(ProductBase):
"""Product schema to return to client."""
id: str
created_at: datetime
updated_at: datetime
class Config:
"""Configuration for the schema."""
from_attributes = True

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

@ -0,0 +1,15 @@
from typing import Optional
from pydantic import BaseModel
class Token(BaseModel):
"""Token schema."""
access_token: str
token_type: str
class TokenPayload(BaseModel):
"""Token payload schema."""
sub: Optional[str] = None
exp: Optional[int] = None

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

@ -0,0 +1,59 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
from app.models.user import UserRole
# Shared properties
class UserBase(BaseModel):
"""Base user schema."""
email: Optional[EmailStr] = None
username: Optional[str] = None
full_name: Optional[str] = None
is_active: Optional[bool] = True
role: Optional[UserRole] = UserRole.CUSTOMER
# Properties to receive via API on creation
class UserCreate(BaseModel):
"""User creation schema."""
email: EmailStr
username: str
password: str
full_name: Optional[str] = None
role: Optional[UserRole] = UserRole.CUSTOMER
# Properties to receive via API on update
class UserUpdate(BaseModel):
"""User update schema."""
email: Optional[EmailStr] = None
username: Optional[str] = None
password: Optional[str] = None
full_name: Optional[str] = None
is_active: Optional[bool] = None
role: Optional[UserRole] = None
# Properties to return to client
class User(UserBase):
"""User schema to return to client."""
id: str
created_at: datetime
updated_at: datetime
class Config:
"""Configuration for the schema."""
from_attributes = True
# Properties properties stored in DB
class UserInDB(User):
"""User schema for database."""
hashed_password: str
class Config:
"""Configuration for the schema."""
from_attributes = True

64
main.py Normal file
View File

@ -0,0 +1,64 @@
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
from app.api.api import api_router
from app.core.config import settings
# Create the app instance
app = FastAPI(
title=settings.PROJECT_NAME,
description="Authentication and Inventory API for ecommerce",
version="0.1.0",
openapi_url="/openapi.json",
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=[origin for origin in settings.BACKEND_CORS_ORIGINS],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Root endpoint
@app.get("/")
async def root():
"""Root endpoint returns basic API information."""
return {
"title": settings.PROJECT_NAME,
"documentation": "/docs",
"health_check": "/health"
}
# Health check endpoint
@app.get("/health")
async def health():
"""Health check endpoint."""
return {"status": "ok"}
# Custom OpenAPI schema
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=settings.PROJECT_NAME,
version="0.1.0",
description="Authentication and Inventory API for ecommerce",
routes=app.routes,
)
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
# Include API router
app.include_router(api_router)
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

1
migrations/__init__.py Normal file
View File

@ -0,0 +1 @@
# This file makes the directory a Python package

81
migrations/env.py Normal file
View File

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

View File

@ -0,0 +1,125 @@
"""Initial schema
Revision ID: 00001
Revises:
Create Date: 2023-10-01
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '00001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create users table
op.create_table(
'users',
sa.Column('id', sa.String(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('full_name', sa.String(), nullable=True),
sa.Column('role', sa.Enum('admin', 'customer', 'staff', name='userrole'), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
# Create categories table
op.create_table(
'categories',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_categories_id'), 'categories', ['id'], unique=False)
op.create_index(op.f('ix_categories_name'), 'categories', ['name'], unique=True)
# Create products table
op.create_table(
'products',
sa.Column('id', sa.String(), 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('sku', sa.String(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('category_id', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ),
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)
op.create_index(op.f('ix_products_sku'), 'products', ['sku'], unique=True)
# Create inventory_items table
op.create_table(
'inventory_items',
sa.Column('id', sa.String(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('status', sa.Enum('in_stock', 'low_stock', 'out_of_stock', 'discontinued', name='inventorystatus'), nullable=False),
sa.Column('location', sa.String(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('product_id', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_inventory_items_id'), 'inventory_items', ['id'], unique=False)
# Create inventory_transactions table
op.create_table(
'inventory_transactions',
sa.Column('id', sa.String(), nullable=False),
sa.Column('product_id', sa.String(), nullable=False),
sa.Column('quantity_change', sa.Integer(), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('transaction_by', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ),
sa.ForeignKeyConstraint(['transaction_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_inventory_transactions_id'), 'inventory_transactions', ['id'], unique=False)
def downgrade() -> None:
# Drop tables in reverse order to handle foreign key constraints
op.drop_index(op.f('ix_inventory_transactions_id'), table_name='inventory_transactions')
op.drop_table('inventory_transactions')
op.drop_index(op.f('ix_inventory_items_id'), table_name='inventory_items')
op.drop_table('inventory_items')
op.drop_index(op.f('ix_products_sku'), table_name='products')
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_categories_name'), table_name='categories')
op.drop_index(op.f('ix_categories_id'), table_name='categories')
op.drop_table('categories')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# Drop enum types after tables
op.execute('DROP TYPE userrole')
op.execute('DROP TYPE inventorystatus')

48
pyproject.toml Normal file
View File

@ -0,0 +1,48 @@
[tool.ruff]
# Enable flake8-bugbear (`B`) rules.
line-length = 88
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "B", "I"]
# Exclude a variety of commonly ignored directories.
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".mypy_cache",
".nox",
".pants.d",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"venv",
]
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
# Ignore certain errors in migrations
extend-ignore = [
"E501", # Line too long (migrations can have long lines)
"B008", # Do not perform function call in argument defaults (common in FastAPI)
]
[tool.ruff.lint.mccabe]
# Unlike Flake8, default to a complexity level of 10.
max-complexity = 10
[tool.ruff.lint.isort]
known-third-party = ["fastapi", "pydantic", "sqlalchemy", "starlette", "uvicorn"]

12
requirements.txt Normal file
View File

@ -0,0 +1,12 @@
fastapi>=0.100.0
uvicorn>=0.23.0
sqlalchemy>=2.0.0
alembic>=1.11.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
email-validator>=2.0.0
python-dotenv>=1.0.0
ruff>=0.0.282