Fix code linting issues

- Fix unused imports in API endpoints
- Add proper __all__ exports in model and schema modules
- Add proper TYPE_CHECKING imports in models to prevent circular imports
- Fix import order in migrations
- Fix long lines in migration scripts
- All ruff checks passing
This commit is contained in:
Automated Action 2025-06-05 16:58:14 +00:00
parent 0ba9b9f79e
commit 439330125e
46 changed files with 2682 additions and 2 deletions

134
README.md
View File

@ -1,3 +1,133 @@
# FastAPI Application
# Small Business Inventory Management System
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform.
## Overview
This project is a comprehensive inventory management system designed for small businesses. It provides a robust REST API for managing products, categories, suppliers, and inventory transactions, along with analytics for business insights.
## Features
- **User Authentication**: Secure login and user management
- **Product Management**: CRUD operations for products with SKU, pricing, and stock levels
- **Category Management**: Organize products into categories
- **Supplier Management**: Track and manage your suppliers
- **Inventory Transactions**: Record purchases, sales, returns, and adjustments
- **Inventory Analytics**: Get insights into your inventory performance
- **Stock Alerts**: Identify low stock and out-of-stock products
## Tech Stack
- **Framework**: FastAPI
- **Database**: SQLite
- **ORM**: SQLAlchemy
- **Authentication**: JWT (JSON Web Tokens)
- **Migrations**: Alembic
## API Endpoints
### Authentication
- `POST /api/v1/auth/login` - Login to get access token
- `POST /api/v1/auth/test-token` - Verify access token
### Users
- `GET /api/v1/users/` - List all users (admin only)
- `POST /api/v1/users/` - Create a new user (admin only)
- `GET /api/v1/users/me` - Get current user info
- `PUT /api/v1/users/me` - Update current user info
- `GET /api/v1/users/{user_id}` - Get user by ID
- `PUT /api/v1/users/{user_id}` - Update user (admin only)
### Categories
- `GET /api/v1/categories/` - List all categories
- `POST /api/v1/categories/` - Create a new category
- `GET /api/v1/categories/{id}` - Get category by ID
- `PUT /api/v1/categories/{id}` - Update category
- `DELETE /api/v1/categories/{id}` - Delete category (admin only)
### Suppliers
- `GET /api/v1/suppliers/` - List all suppliers
- `POST /api/v1/suppliers/` - Create a new supplier
- `GET /api/v1/suppliers/{id}` - Get supplier by ID
- `PUT /api/v1/suppliers/{id}` - Update supplier
- `DELETE /api/v1/suppliers/{id}` - Delete supplier (admin only)
### Products
- `GET /api/v1/products/` - List all products
- `POST /api/v1/products/` - Create a new product
- `GET /api/v1/products/{id}` - Get product by ID
- `PUT /api/v1/products/{id}` - Update product
- `DELETE /api/v1/products/{id}` - Delete product
- `GET /api/v1/products/by-category/{category_id}` - Get products by category
- `GET /api/v1/products/by-supplier/{supplier_id}` - Get products by supplier
- `GET /api/v1/products/low-stock` - Get low stock products
- `GET /api/v1/products/out-of-stock` - Get out of stock products
### Inventory Transactions
- `POST /api/v1/inventory-transactions/` - Create a new transaction
- `GET /api/v1/inventory-transactions/` - List all transactions
- `GET /api/v1/inventory-transactions/{id}` - Get transaction by ID
- `GET /api/v1/inventory-transactions/by-product/{product_id}` - Get transactions by product
- `GET /api/v1/inventory-transactions/by-type/{transaction_type}` - Get transactions by type
### Analytics
- `GET /api/v1/analytics/inventory-summary` - Get inventory summary
- `GET /api/v1/analytics/transaction-history` - Get transaction history
- `GET /api/v1/analytics/product-performance` - Get product performance
- `GET /api/v1/analytics/category-performance` - Get category performance
## Installation and Setup
### Prerequisites
- Python 3.8+
- pip
### Setting Up the Environment
1. Clone the repository:
```bash
git clone <repository-url>
cd smallbusinessinventorymanagementsystem
```
2. Install the required packages:
```bash
pip install -r requirements.txt
```
3. Create a `.env` file in the root directory (optional):
```
SECRET_KEY=your_secret_key_here
ACCESS_TOKEN_EXPIRE_MINUTES=43200 # 30 days
```
### Database Setup
1. The application uses SQLite by default, stored at `/app/storage/db/db.sqlite`.
2. Run the database migrations:
```bash
alembic upgrade head
```
### Running the Application
1. Start the FastAPI server:
```bash
uvicorn main:app --reload
```
2. Access the API documentation at:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| SECRET_KEY | Secret key for JWT encoding | "CHANGE_ME_IN_PRODUCTION" |
| ACCESS_TOKEN_EXPIRE_MINUTES | Token expiration time in minutes | 10080 (7 days) |
| SQLALCHEMY_DATABASE_URL | Database connection URL | "sqlite:///app/storage/db/db.sqlite" |
## Development
### Creating a Superuser
To create a superuser account, you can use the API or add a user directly to the database with the `is_superuser` flag set to `True`.
### Running Tests
```bash
pytest
```
## License
This project is licensed under the MIT License - see the LICENSE file for details.

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
# Specify the SQLite URL
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 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

0
app/__init__.py Normal file
View File

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

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

@ -0,0 +1,62 @@
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 import security
from app.core.config import settings
from app.db.session import get_db
reusable_oauth2 = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login"
)
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(reusable_oauth2)
) -> models.User:
"""
Dependency to get the current user from the token.
"""
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
token_data = schemas.TokenPayload(**payload)
except (jwt.JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)
user = crud.user.get(db, id=token_data.sub)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
def get_current_active_user(
current_user: models.User = Depends(get_current_user),
) -> models.User:
"""
Dependency to get the current active user.
"""
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
def get_current_active_superuser(
current_user: models.User = Depends(get_current_user),
) -> models.User:
"""
Dependency to get the current active superuser.
"""
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The user doesn't have enough privileges",
)
return current_user

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

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

@ -0,0 +1,40 @@
from fastapi import APIRouter
from app.api.v1.endpoints import (
analytics,
auth,
categories,
inventory_transactions,
products,
suppliers,
users,
)
api_router = APIRouter()
# Auth routes
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
# User routes
api_router.include_router(users.router, prefix="/users", tags=["users"])
# Category routes
api_router.include_router(categories.router, prefix="/categories", tags=["categories"])
# Supplier routes
api_router.include_router(suppliers.router, prefix="/suppliers", tags=["suppliers"])
# Product routes
api_router.include_router(products.router, prefix="/products", tags=["products"])
# Inventory transaction routes
api_router.include_router(
inventory_transactions.router,
prefix="/inventory-transactions",
tags=["inventory-transactions"],
)
# Analytics routes
api_router.include_router(
analytics.router, prefix="/analytics", tags=["analytics"]
)

View File

View File

@ -0,0 +1,250 @@
from datetime import datetime, timedelta
from typing import Any
from fastapi import APIRouter, Depends
from sqlalchemy import func
from sqlalchemy.orm import Session
from app import crud, models
from app.api import deps
from app.models.inventory_transaction import TransactionType
router = APIRouter()
@router.get("/inventory-summary")
def get_inventory_summary(
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get summary of inventory status.
"""
# Get all products belonging to the user
products = crud.product.get_multi_by_owner(db, owner_id=current_user.id)
# Calculate summary statistics
total_products = len(products)
total_inventory_value = sum(p.quantity * p.cost for p in products)
total_retail_value = sum(p.quantity * p.price for p in products)
potential_profit = total_retail_value - total_inventory_value
low_stock_count = len([p for p in products if 0 < p.quantity <= p.reorder_level])
out_of_stock_count = len([p for p in products if p.quantity == 0])
in_stock_count = total_products - low_stock_count - out_of_stock_count
return {
"total_products": total_products,
"total_inventory_value": round(total_inventory_value, 2),
"total_retail_value": round(total_retail_value, 2),
"potential_profit": round(potential_profit, 2),
"inventory_status": {
"in_stock": in_stock_count,
"low_stock": low_stock_count,
"out_of_stock": out_of_stock_count
}
}
@router.get("/transaction-history")
def get_transaction_history(
db: Session = Depends(deps.get_db),
days: int = 30,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get transaction history for the specified number of days.
"""
# Get all products belonging to the user
products = crud.product.get_multi_by_owner(db, owner_id=current_user.id)
product_ids = [p.id for p in products]
# Get date range
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=days)
# Get transactions for those products within the date range
transactions = (
db.query(
models.InventoryTransaction.transaction_type,
func.count().label("count"),
func.sum(models.InventoryTransaction.quantity).label("total_quantity"),
)
.filter(
models.InventoryTransaction.product_id.in_(product_ids),
models.InventoryTransaction.transaction_date >= start_date,
models.InventoryTransaction.transaction_date <= end_date,
)
.group_by(models.InventoryTransaction.transaction_type)
.all()
)
# Format results
result = {}
for t_type in TransactionType:
result[t_type.value] = {"count": 0, "total_quantity": 0}
for t_type, count, total_quantity in transactions:
result[t_type.value] = {
"count": count,
"total_quantity": total_quantity or 0
}
return {
"period_days": days,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"transactions_by_type": result
}
@router.get("/product-performance")
def get_product_performance(
db: Session = Depends(deps.get_db),
days: int = 30,
limit: int = 10,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get top-selling products for the specified number of days.
"""
# Get all products belonging to the user
products = crud.product.get_multi_by_owner(db, owner_id=current_user.id)
product_dict = {p.id: p for p in products}
# Get date range
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=days)
# Get sales transactions grouped by product
sales = (
db.query(
models.InventoryTransaction.product_id,
func.sum(models.InventoryTransaction.quantity).label("quantity_sold"),
)
.filter(
models.InventoryTransaction.product_id.in_(product_dict.keys()),
models.InventoryTransaction.transaction_type == TransactionType.SALE,
models.InventoryTransaction.transaction_date >= start_date,
models.InventoryTransaction.transaction_date <= end_date,
)
.group_by(models.InventoryTransaction.product_id)
.order_by(func.sum(models.InventoryTransaction.quantity).desc())
.limit(limit)
.all()
)
# Format results
top_selling = []
for product_id, quantity_sold in sales:
product = product_dict.get(product_id)
if product:
top_selling.append({
"id": product.id,
"name": product.name,
"sku": product.sku,
"quantity_sold": quantity_sold,
"revenue": round(quantity_sold * product.price, 2),
"profit": round(quantity_sold * (product.price - product.cost), 2),
})
return {
"period_days": days,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"top_selling_products": top_selling
}
@router.get("/category-performance")
def get_category_performance(
db: Session = Depends(deps.get_db),
days: int = 30,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get sales performance by category for the specified number of days.
"""
# Get all products belonging to the user
products = crud.product.get_multi_by_owner(db, owner_id=current_user.id)
# Collect category IDs
category_ids = set()
for p in products:
if p.category_id:
category_ids.add(p.category_id)
# Get categories
categories = crud.category.get_multi_by_ids(db, ids=list(category_ids))
category_dict = {c.id: c for c in categories}
# Get date range
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=days)
# Get sales transactions for these products
sales = (
db.query(
models.Product.category_id,
func.sum(models.InventoryTransaction.quantity).label("quantity_sold"),
)
.join(
models.InventoryTransaction,
models.InventoryTransaction.product_id == models.Product.id
)
.filter(
models.Product.owner_id == current_user.id,
models.InventoryTransaction.transaction_type == TransactionType.SALE,
models.InventoryTransaction.transaction_date >= start_date,
models.InventoryTransaction.transaction_date <= end_date,
)
.group_by(models.Product.category_id)
.all()
)
# Format results
category_performance = []
for category_id, quantity_sold in sales:
# Skip None category
if not category_id:
continue
category = category_dict.get(category_id)
if category:
# Calculate revenue and profit for this category
revenue = 0
profit = 0
for p in products:
if p.category_id == category_id:
# Get sales for this specific product
product_sales = (
db.query(func.sum(models.InventoryTransaction.quantity))
.filter(
models.InventoryTransaction.product_id == p.id,
models.InventoryTransaction.transaction_type == TransactionType.SALE,
models.InventoryTransaction.transaction_date >= start_date,
models.InventoryTransaction.transaction_date <= end_date,
)
.scalar() or 0
)
revenue += product_sales * p.price
profit += product_sales * (p.price - p.cost)
category_performance.append({
"id": category.id,
"name": category.name,
"quantity_sold": quantity_sold,
"revenue": round(revenue, 2),
"profit": round(profit, 2),
})
# Sort by revenue
category_performance.sort(key=lambda x: x["revenue"], reverse=True)
return {
"period_days": days,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
"category_performance": category_performance
}

View File

@ -0,0 +1,49 @@
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, schemas
from app.api import deps
from app.core import security
from app.core.config import settings
router = APIRouter()
@router.post("/login", response_model=schemas.Token)
def login_access_token(
db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends()
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests.
"""
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",
)
elif 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)
return {
"access_token": security.create_access_token(
user.id, expires_delta=access_token_expires
),
"token_type": "bearer",
}
@router.post("/test-token", response_model=schemas.User)
def test_token(current_user: schemas.User = Depends(deps.get_current_user)) -> Any:
"""
Test access token.
"""
return current_user

View File

@ -0,0 +1,88 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
router = APIRouter()
@router.get("/", 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.category.get_multi(db, skip=skip, limit=limit)
return categories
@router.post("/", 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_active_user),
) -> Any:
"""
Create new category.
"""
category = crud.category.create(db, obj_in=category_in)
return category
@router.get("/{id}", response_model=schemas.Category)
def read_category(
*,
db: Session = Depends(deps.get_db),
id: str,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get category by ID.
"""
category = crud.category.get(db, id=id)
if not category:
raise HTTPException(status_code=404, detail="Category not found")
return category
@router.put("/{id}", response_model=schemas.Category)
def update_category(
*,
db: Session = Depends(deps.get_db),
id: str,
category_in: schemas.CategoryUpdate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Update a category.
"""
category = crud.category.get(db, id=id)
if not category:
raise HTTPException(status_code=404, detail="Category not found")
category = crud.category.update(db, db_obj=category, obj_in=category_in)
return category
@router.delete("/{id}", status_code=204, response_model=None)
def delete_category(
*,
db: Session = Depends(deps.get_db),
id: str,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Delete a category.
"""
category = crud.category.get(db, id=id)
if not category:
raise HTTPException(status_code=404, detail="Category not found")
crud.category.remove(db, id=id)
return None

View File

@ -0,0 +1,135 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
from app.models.inventory_transaction import TransactionType
router = APIRouter()
@router.post("/", response_model=schemas.InventoryTransaction)
def create_inventory_transaction(
*,
db: Session = Depends(deps.get_db),
transaction_in: schemas.InventoryTransactionCreate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Create new inventory transaction.
"""
# Verify product exists and belongs to the current user
product = crud.product.get(db, id=transaction_in.product_id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
if product.owner_id != current_user.id and not current_user.is_superuser:
raise HTTPException(status_code=403, detail="Not enough permissions")
try:
transaction = crud.inventory_transaction.create_with_product_update(
db, obj_in=transaction_in
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return transaction
@router.get("/", response_model=List[schemas.InventoryTransaction])
def read_inventory_transactions(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve inventory transactions.
"""
# Get all products belonging to the user
products = crud.product.get_multi_by_owner(db, owner_id=current_user.id)
product_ids = [p.id for p in products]
# Get transactions for those products
transactions = db.query(models.InventoryTransaction).filter(
models.InventoryTransaction.product_id.in_(product_ids)
).order_by(
models.InventoryTransaction.transaction_date.desc()
).offset(skip).limit(limit).all()
return transactions
@router.get("/by-product/{product_id}", response_model=List[schemas.InventoryTransaction])
def read_inventory_transactions_by_product(
*,
db: Session = Depends(deps.get_db),
product_id: str,
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve inventory transactions by product.
"""
# Verify product exists and belongs to the current user
product = crud.product.get(db, id=product_id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
if product.owner_id != current_user.id and not current_user.is_superuser:
raise HTTPException(status_code=403, detail="Not enough permissions")
transactions = crud.inventory_transaction.get_by_product(
db, product_id=product_id, skip=skip, limit=limit
)
return transactions
@router.get("/by-type/{transaction_type}", response_model=List[schemas.InventoryTransaction])
def read_inventory_transactions_by_type(
*,
db: Session = Depends(deps.get_db),
transaction_type: TransactionType,
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve inventory transactions by transaction type.
"""
# Get all products belonging to the user
products = crud.product.get_multi_by_owner(db, owner_id=current_user.id)
product_ids = [p.id for p in products]
# Get transactions for those products with the specified type
transactions = db.query(models.InventoryTransaction).filter(
models.InventoryTransaction.product_id.in_(product_ids),
models.InventoryTransaction.transaction_type == transaction_type
).order_by(
models.InventoryTransaction.transaction_date.desc()
).offset(skip).limit(limit).all()
return transactions
@router.get("/{id}", response_model=schemas.InventoryTransaction)
def read_inventory_transaction(
*,
db: Session = Depends(deps.get_db),
id: str,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get inventory transaction by ID.
"""
transaction = crud.inventory_transaction.get(db, id=id)
if not transaction:
raise HTTPException(status_code=404, detail="Inventory transaction not found")
# Verify product belongs to the current user
product = crud.product.get(db, id=transaction.product_id)
if product.owner_id != current_user.id and not current_user.is_superuser:
raise HTTPException(status_code=403, detail="Not enough permissions")
return transaction

View File

@ -0,0 +1,178 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
router = APIRouter()
@router.get("/", response_model=List[schemas.Product])
def read_products(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve products.
"""
products = crud.product.get_multi_by_owner(
db, owner_id=current_user.id, skip=skip, limit=limit
)
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_active_user),
) -> Any:
"""
Create new product.
"""
# Check if product with same SKU already exists
product = crud.product.get_by_sku(db, sku=product_in.sku)
if product:
raise HTTPException(
status_code=400,
detail="A product with this SKU already exists.",
)
product = crud.product.create_with_owner(
db, obj_in=product_in, owner_id=current_user.id
)
return product
@router.get("/{id}", response_model=schemas.Product)
def read_product(
*,
db: Session = Depends(deps.get_db),
id: str,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get product by ID.
"""
product = crud.product.get(db, id=id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
if product.owner_id != current_user.id and not current_user.is_superuser:
raise HTTPException(status_code=403, detail="Not enough permissions")
return product
@router.put("/{id}", response_model=schemas.Product)
def update_product(
*,
db: Session = Depends(deps.get_db),
id: str,
product_in: schemas.ProductUpdate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Update a product.
"""
product = crud.product.get(db, id=id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
if product.owner_id != current_user.id and not current_user.is_superuser:
raise HTTPException(status_code=403, detail="Not enough permissions")
product = crud.product.update(db, db_obj=product, obj_in=product_in)
return product
@router.delete("/{id}", status_code=204, response_model=None)
def delete_product(
*,
db: Session = Depends(deps.get_db),
id: str,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Delete a product.
"""
product = crud.product.get(db, id=id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
if product.owner_id != current_user.id and not current_user.is_superuser:
raise HTTPException(status_code=403, detail="Not enough permissions")
crud.product.remove(db, id=id)
return None
@router.get("/by-category/{category_id}", response_model=List[schemas.Product])
def read_products_by_category(
*,
db: Session = Depends(deps.get_db),
category_id: str,
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve products by category.
"""
products = crud.product.get_multi_by_category(
db, category_id=category_id, skip=skip, limit=limit
)
# Filter by owner
return [p for p in products if p.owner_id == current_user.id]
@router.get("/by-supplier/{supplier_id}", response_model=List[schemas.Product])
def read_products_by_supplier(
*,
db: Session = Depends(deps.get_db),
supplier_id: str,
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve products by supplier.
"""
products = crud.product.get_multi_by_supplier(
db, supplier_id=supplier_id, skip=skip, limit=limit
)
# Filter by owner
return [p for p in products if p.owner_id == current_user.id]
@router.get("/low-stock", response_model=List[schemas.Product])
def read_low_stock_products(
*,
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve products with stock levels below reorder level.
"""
products = crud.product.get_low_stock_products(
db, owner_id=current_user.id, skip=skip, limit=limit
)
return products
@router.get("/out-of-stock", response_model=List[schemas.Product])
def read_out_of_stock_products(
*,
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve products with zero stock.
"""
products = crud.product.get_out_of_stock_products(
db, owner_id=current_user.id, skip=skip, limit=limit
)
return products

View File

@ -0,0 +1,88 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
router = APIRouter()
@router.get("/", response_model=List[schemas.Supplier])
def read_suppliers(
db: Session = Depends(deps.get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Retrieve suppliers.
"""
suppliers = crud.supplier.get_multi(db, skip=skip, limit=limit)
return suppliers
@router.post("/", response_model=schemas.Supplier)
def create_supplier(
*,
db: Session = Depends(deps.get_db),
supplier_in: schemas.SupplierCreate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Create new supplier.
"""
supplier = crud.supplier.create(db, obj_in=supplier_in)
return supplier
@router.get("/{id}", response_model=schemas.Supplier)
def read_supplier(
*,
db: Session = Depends(deps.get_db),
id: str,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get supplier by ID.
"""
supplier = crud.supplier.get(db, id=id)
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
return supplier
@router.put("/{id}", response_model=schemas.Supplier)
def update_supplier(
*,
db: Session = Depends(deps.get_db),
id: str,
supplier_in: schemas.SupplierUpdate,
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Update a supplier.
"""
supplier = crud.supplier.get(db, id=id)
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
supplier = crud.supplier.update(db, db_obj=supplier, obj_in=supplier_in)
return supplier
@router.delete("/{id}", status_code=204, response_model=None)
def delete_supplier(
*,
db: Session = Depends(deps.get_db),
id: str,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Delete a supplier.
"""
supplier = crud.supplier.get(db, id=id)
if not supplier:
raise HTTPException(status_code=404, detail="Supplier not found")
crud.supplier.remove(db, id=id)
return None

View File

@ -0,0 +1,120 @@
from typing import Any, List
from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi.encoders import jsonable_encoder
from pydantic import EmailStr
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_active_superuser),
) -> Any:
"""
Retrieve users.
"""
users = crud.user.get_multi(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_active_superuser),
) -> Any:
"""
Create new user.
"""
user = crud.user.get_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=400,
detail="A user with this email already exists.",
)
user = crud.user.create(db, obj_in=user_in)
return user
@router.put("/me", response_model=schemas.User)
def update_user_me(
*,
db: Session = Depends(deps.get_db),
password: str = Body(None),
full_name: str = Body(None),
email: EmailStr = Body(None),
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Update own user.
"""
current_user_data = jsonable_encoder(current_user)
user_in = schemas.UserUpdate(**current_user_data)
if password is not None:
user_in.password = password
if full_name is not None:
user_in.full_name = full_name
if email is not None:
user_in.email = email
user = crud.user.update(db, db_obj=current_user, obj_in=user_in)
return user
@router.get("/me", response_model=schemas.User)
def read_user_me(
current_user: models.User = Depends(deps.get_current_active_user),
) -> Any:
"""
Get current user.
"""
return current_user
@router.get("/{user_id}", response_model=schemas.User)
def read_user_by_id(
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(db, id=user_id)
if user == current_user:
return user
if not crud.user.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=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_superuser),
) -> Any:
"""
Update a user.
"""
user = crud.user.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=404,
detail="The user with this ID does not exist in the system",
)
user = crud.user.update(db, db_obj=user, obj_in=user_in)
return user

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

@ -0,0 +1,66 @@
from pathlib import Path
from typing import List
from pydantic import AnyHttpUrl, EmailStr, validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
PROJECT_NAME: str = "Small Business Inventory Management System"
DESCRIPTION: str = "API for managing inventory for small businesses"
VERSION: str = "0.1.0"
API_V1_STR: str = "/api/v1"
# SECURITY
SECRET_KEY: str = "CHANGE_ME_IN_PRODUCTION"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
# Database setup
DB_DIR = Path("/app") / "storage" / "db"
DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite"
# CORS
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: str | List[str]) -> List[str] | str:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, (list, str)):
return v
raise ValueError(v)
# Email
SMTP_TLS: bool = True
SMTP_PORT: int | None = None
SMTP_HOST: str | None = None
SMTP_USER: str | None = None
SMTP_PASSWORD: str | None = None
EMAILS_FROM_EMAIL: EmailStr | None = None
EMAILS_FROM_NAME: str | None = None
@validator("EMAILS_FROM_NAME")
def get_project_name(cls, v: str | None, values: dict) -> str:
if not v:
return values["PROJECT_NAME"]
return v
EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
EMAIL_TEMPLATES_DIR: str = "/app/app/email-templates/build"
EMAILS_ENABLED: bool = False
@validator("EMAILS_ENABLED", pre=True)
def get_emails_enabled(cls, v: bool, values: dict) -> bool:
return bool(
values.get("SMTP_HOST")
and values.get("SMTP_PORT")
and values.get("EMAILS_FROM_EMAIL")
)
class Config:
case_sensitive = True
env_file = ".env"
settings = Settings()

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

@ -0,0 +1,42 @@
from datetime import datetime, timedelta
from typing import Any, Optional
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
def create_access_token(
subject: str | Any, expires_delta: Optional[timedelta] = None
) -> str:
"""
Create a JWT token with the given subject and expiration time.
"""
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=ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a password against a hash.
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Hash a password.
"""
return pwd_context.hash(password)

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

@ -0,0 +1,7 @@
from app.crud.crud_category import category
from app.crud.crud_inventory_transaction import inventory_transaction
from app.crud.crud_product import product
from app.crud.crud_supplier import supplier
from app.crud.crud_user import user
__all__ = ["category", "inventory_transaction", "product", "supplier", "user"]

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

@ -0,0 +1,84 @@
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_class import Base
ModelType = TypeVar("ModelType", bound=Base)
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
"""
Base class for CRUD operations.
"""
def __init__(self, model: Type[ModelType]):
"""
CRUD object with default methods to Create, Read, Update, Delete (CRUD).
**Parameters**
* `model`: A SQLAlchemy model class
* `schema`: A Pydantic model (schema) class
"""
self.model = model
def get(self, db: Session, id: Any) -> Optional[ModelType]:
"""
Get a record by ID.
"""
return db.query(self.model).filter(self.model.id == id).first()
def get_multi(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[ModelType]:
"""
Get multiple records.
"""
return db.query(self.model).offset(skip).limit(limit).all()
def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
"""
Create a new record.
"""
obj_in_data = jsonable_encoder(obj_in)
db_obj = self.model(**obj_in_data) # type: ignore
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self,
db: Session,
*,
db_obj: ModelType,
obj_in: Union[UpdateSchemaType, Dict[str, Any]]
) -> ModelType:
"""
Update a record.
"""
obj_data = jsonable_encoder(db_obj)
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.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: Any) -> ModelType:
"""
Delete a record.
"""
obj = db.query(self.model).get(id)
db.delete(obj)
db.commit()
return obj

44
app/crud/crud_category.py Normal file
View File

@ -0,0 +1,44 @@
import uuid
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.category import Category
from app.schemas.category import CategoryCreate, CategoryUpdate
class CRUDCategory(CRUDBase[Category, CategoryCreate, CategoryUpdate]):
"""
CRUD operations for Category model.
"""
def get_by_name(self, db: Session, *, name: str) -> Optional[Category]:
"""
Get a category by name.
"""
return db.query(Category).filter(Category.name == name).first()
def create(self, db: Session, *, obj_in: CategoryCreate) -> Category:
"""
Create a new category.
"""
db_obj = Category(
id=str(uuid.uuid4()),
name=obj_in.name,
description=obj_in.description,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def get_multi_by_ids(
self, db: Session, *, ids: List[str], skip: int = 0, limit: int = 100
) -> List[Category]:
"""
Get multiple categories by IDs.
"""
return db.query(Category).filter(Category.id.in_(ids)).offset(skip).limit(limit).all()
category = CRUDCategory(Category)

View File

@ -0,0 +1,105 @@
import uuid
from datetime import datetime
from typing import List
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.inventory_transaction import InventoryTransaction, TransactionType
from app.models.product import Product
from app.schemas.inventory_transaction import (
InventoryTransactionCreate,
InventoryTransactionUpdate,
)
class CRUDInventoryTransaction(
CRUDBase[InventoryTransaction, InventoryTransactionCreate, InventoryTransactionUpdate]
):
"""
CRUD operations for InventoryTransaction model.
"""
def create_with_product_update(
self, db: Session, *, obj_in: InventoryTransactionCreate
) -> InventoryTransaction:
"""
Create a new inventory transaction and update product quantity.
"""
# Get the product
product = db.query(Product).filter(Product.id == obj_in.product_id).first()
if not product:
raise ValueError("Product not found")
# Calculate the quantity change based on transaction type
if obj_in.transaction_type == TransactionType.PURCHASE:
product.quantity += obj_in.quantity
elif obj_in.transaction_type == TransactionType.SALE:
if product.quantity < obj_in.quantity:
raise ValueError("Not enough inventory for sale")
product.quantity -= obj_in.quantity
elif obj_in.transaction_type == TransactionType.ADJUSTMENT:
product.quantity += obj_in.quantity # Can be negative for reduction
elif obj_in.transaction_type == TransactionType.RETURN:
product.quantity += obj_in.quantity
elif obj_in.transaction_type == TransactionType.TRANSFER:
if product.quantity < obj_in.quantity:
raise ValueError("Not enough inventory for transfer")
product.quantity -= obj_in.quantity
# Create the transaction
db_obj = InventoryTransaction(
id=str(uuid.uuid4()),
transaction_type=obj_in.transaction_type,
quantity=obj_in.quantity,
transaction_date=obj_in.transaction_date or datetime.utcnow(),
notes=obj_in.notes,
unit_price=obj_in.unit_price,
reference_number=obj_in.reference_number,
product_id=obj_in.product_id,
)
# Update the database
db.add(db_obj)
db.add(product)
db.commit()
db.refresh(db_obj)
return db_obj
def get_by_product(
self, db: Session, *, product_id: str, skip: int = 0, limit: int = 100
) -> List[InventoryTransaction]:
"""
Get transactions by product ID.
"""
return (
db.query(InventoryTransaction)
.filter(InventoryTransaction.product_id == product_id)
.order_by(InventoryTransaction.transaction_date.desc())
.offset(skip)
.limit(limit)
.all()
)
def get_by_transaction_type(
self,
db: Session,
*,
transaction_type: TransactionType,
skip: int = 0,
limit: int = 100
) -> List[InventoryTransaction]:
"""
Get transactions by transaction type.
"""
return (
db.query(InventoryTransaction)
.filter(InventoryTransaction.transaction_type == transaction_type)
.order_by(InventoryTransaction.transaction_date.desc())
.offset(skip)
.limit(limit)
.all()
)
inventory_transaction = CRUDInventoryTransaction(InventoryTransaction)

118
app/crud/crud_product.py Normal file
View File

@ -0,0 +1,118 @@
import uuid
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]):
"""
CRUD operations for Product model.
"""
def get_by_sku(self, db: Session, *, sku: str) -> Optional[Product]:
"""
Get a product by SKU.
"""
return db.query(Product).filter(Product.sku == sku).first()
def create_with_owner(
self, db: Session, *, obj_in: ProductCreate, owner_id: str
) -> Product:
"""
Create a new product with owner ID.
"""
db_obj = Product(
id=str(uuid.uuid4()),
name=obj_in.name,
description=obj_in.description,
sku=obj_in.sku,
price=obj_in.price,
cost=obj_in.cost,
quantity=obj_in.quantity,
reorder_level=obj_in.reorder_level,
category_id=obj_in.category_id,
supplier_id=obj_in.supplier_id,
owner_id=owner_id,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def get_multi_by_owner(
self, db: Session, *, owner_id: str, skip: int = 0, limit: int = 100
) -> List[Product]:
"""
Get multiple products by owner ID.
"""
return (
db.query(Product)
.filter(Product.owner_id == owner_id)
.offset(skip)
.limit(limit)
.all()
)
def get_multi_by_category(
self, db: Session, *, category_id: str, skip: int = 0, limit: int = 100
) -> List[Product]:
"""
Get multiple products by category ID.
"""
return (
db.query(Product)
.filter(Product.category_id == category_id)
.offset(skip)
.limit(limit)
.all()
)
def get_multi_by_supplier(
self, db: Session, *, supplier_id: str, skip: int = 0, limit: int = 100
) -> List[Product]:
"""
Get multiple products by supplier ID.
"""
return (
db.query(Product)
.filter(Product.supplier_id == supplier_id)
.offset(skip)
.limit(limit)
.all()
)
def get_low_stock_products(
self, db: Session, *, owner_id: str, skip: int = 0, limit: int = 100
) -> List[Product]:
"""
Get products with stock levels below reorder level.
"""
return (
db.query(Product)
.filter(Product.owner_id == owner_id)
.filter(Product.quantity <= Product.reorder_level)
.offset(skip)
.limit(limit)
.all()
)
def get_out_of_stock_products(
self, db: Session, *, owner_id: str, skip: int = 0, limit: int = 100
) -> List[Product]:
"""
Get products with zero stock.
"""
return (
db.query(Product)
.filter(Product.owner_id == owner_id)
.filter(Product.quantity == 0)
.offset(skip)
.limit(limit)
.all()
)
product = CRUDProduct(Product)

48
app/crud/crud_supplier.py Normal file
View File

@ -0,0 +1,48 @@
import uuid
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.supplier import Supplier
from app.schemas.supplier import SupplierCreate, SupplierUpdate
class CRUDSupplier(CRUDBase[Supplier, SupplierCreate, SupplierUpdate]):
"""
CRUD operations for Supplier model.
"""
def get_by_name(self, db: Session, *, name: str) -> Optional[Supplier]:
"""
Get a supplier by name.
"""
return db.query(Supplier).filter(Supplier.name == name).first()
def create(self, db: Session, *, obj_in: SupplierCreate) -> Supplier:
"""
Create a new supplier.
"""
db_obj = Supplier(
id=str(uuid.uuid4()),
name=obj_in.name,
contact_name=obj_in.contact_name,
email=obj_in.email,
phone=obj_in.phone,
address=obj_in.address,
notes=obj_in.notes,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def get_multi_by_ids(
self, db: Session, *, ids: List[str], skip: int = 0, limit: int = 100
) -> List[Supplier]:
"""
Get multiple suppliers by IDs.
"""
return db.query(Supplier).filter(Supplier.id.in_(ids)).offset(skip).limit(limit).all()
supplier = CRUDSupplier(Supplier)

78
app/crud/crud_user.py Normal file
View File

@ -0,0 +1,78 @@
import uuid
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]):
"""
CRUD operations for User model.
"""
def get_by_email(self, db: Session, *, email: str) -> Optional[User]:
"""
Get a user by email.
"""
return db.query(User).filter(User.email == email).first()
def create(self, db: Session, *, obj_in: UserCreate) -> User:
"""
Create a new user.
"""
db_obj = User(
id=str(uuid.uuid4()),
email=obj_in.email,
hashed_password=get_password_hash(obj_in.password),
full_name=obj_in.full_name,
is_superuser=obj_in.is_superuser,
)
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.
"""
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.dict(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
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.
"""
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.
"""
return user.is_active
def is_superuser(self, user: User) -> bool:
"""
Check if a user is a superuser.
"""
return user.is_superuser
user = CRUDUser(User)

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

@ -0,0 +1,8 @@
# Import all the models, so that Base has them before being
# imported by Alembic
from app.db.base_class import Base # noqa
from app.models.user import User # noqa
from app.models.category import Category # noqa
from app.models.supplier import Supplier # noqa
from app.models.product import Product # noqa
from app.models.inventory_transaction import InventoryTransaction # noqa

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

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

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

@ -0,0 +1,20 @@
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}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db():
"""
Dependency function to get a database session.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()

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

@ -0,0 +1,7 @@
from app.models.category import Category
from app.models.inventory_transaction import InventoryTransaction, TransactionType
from app.models.product import Product
from app.models.supplier import Supplier
from app.models.user import User
__all__ = ["Category", "InventoryTransaction", "TransactionType", "Product", "Supplier", "User"]

24
app/models/category.py Normal file
View File

@ -0,0 +1,24 @@
from datetime import datetime
from typing import TYPE_CHECKING, List
from sqlalchemy import Column, DateTime, String
from sqlalchemy.orm import Mapped, relationship
from app.db.base_class import Base
if TYPE_CHECKING:
from app.models.product import Product
class Category(Base):
"""
Database model for product categories.
"""
id = Column(String, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
description = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
products: Mapped[List["Product"]] = relationship("Product", back_populates="category")

View File

@ -0,0 +1,42 @@
from datetime import datetime
from enum import Enum as PyEnum
from typing import TYPE_CHECKING
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, relationship
from app.db.base_class import Base
if TYPE_CHECKING:
from app.models.product import Product
class TransactionType(str, PyEnum):
"""
Enum for inventory transaction types.
"""
PURCHASE = "purchase"
SALE = "sale"
ADJUSTMENT = "adjustment"
RETURN = "return"
TRANSFER = "transfer"
class InventoryTransaction(Base):
"""
Database model for inventory transactions.
"""
id = Column(String, primary_key=True, index=True)
transaction_type = Column(Enum(TransactionType), nullable=False)
quantity = Column(Integer, nullable=False)
transaction_date = Column(DateTime, default=datetime.utcnow)
notes = Column(String, nullable=True)
unit_price = Column(Float, nullable=True)
reference_number = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Foreign keys
product_id = Column(String, ForeignKey("product.id"), nullable=False)
# Relationships
product: Mapped["Product"] = relationship("Product", back_populates="inventory_transactions")

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

@ -0,0 +1,42 @@
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, relationship
from app.db.base_class import Base
if TYPE_CHECKING:
from app.models.category import Category
from app.models.inventory_transaction import InventoryTransaction
from app.models.supplier import Supplier
from app.models.user import User
class Product(Base):
"""
Database model for products.
"""
id = Column(String, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
description = Column(String, nullable=True)
sku = Column(String, unique=True, index=True, nullable=False)
price = Column(Float, nullable=False)
cost = Column(Float, nullable=False)
quantity = Column(Integer, default=0)
reorder_level = Column(Integer, default=10)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Foreign keys
category_id = Column(String, ForeignKey("category.id"), nullable=True)
supplier_id = Column(String, ForeignKey("supplier.id"), nullable=True)
owner_id = Column(String, ForeignKey("user.id"), nullable=False)
# Relationships
category: Mapped[Optional["Category"]] = relationship("Category", back_populates="products")
supplier: Mapped[Optional["Supplier"]] = relationship("Supplier", back_populates="products")
owner: Mapped["User"] = relationship("User", back_populates="products")
inventory_transactions: Mapped[List["InventoryTransaction"]] = relationship(
"InventoryTransaction", back_populates="product"
)

28
app/models/supplier.py Normal file
View File

@ -0,0 +1,28 @@
from datetime import datetime
from typing import TYPE_CHECKING, List
from sqlalchemy import Column, DateTime, String
from sqlalchemy.orm import Mapped, relationship
from app.db.base_class import Base
if TYPE_CHECKING:
from app.models.product import Product
class Supplier(Base):
"""
Database model for suppliers.
"""
id = Column(String, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
contact_name = Column(String, nullable=True)
email = Column(String, nullable=True)
phone = Column(String, nullable=True)
address = Column(String, nullable=True)
notes = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
products: Mapped[List["Product"]] = relationship("Product", back_populates="supplier")

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

@ -0,0 +1,27 @@
from datetime import datetime
from typing import TYPE_CHECKING, List
from sqlalchemy import Boolean, Column, DateTime, String
from sqlalchemy.orm import Mapped, relationship
from app.db.base_class import Base
if TYPE_CHECKING:
from app.models.product import Product
class User(Base):
"""
Database model for users.
"""
id = Column(String, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
full_name = Column(String, nullable=True)
hashed_password = Column(String, nullable=False)
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
products: Mapped[List["Product"]] = relationship("Product", back_populates="owner")

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

@ -0,0 +1,19 @@
from app.schemas.category import Category, CategoryCreate, CategoryUpdate
from app.schemas.inventory_transaction import (
InventoryTransaction,
InventoryTransactionCreate,
InventoryTransactionUpdate,
)
from app.schemas.product import Product, ProductCreate, ProductUpdate
from app.schemas.supplier import Supplier, SupplierCreate, SupplierUpdate
from app.schemas.token import Token, TokenPayload
from app.schemas.user import User, UserCreate, UserUpdate
__all__ = [
"Category", "CategoryCreate", "CategoryUpdate",
"InventoryTransaction", "InventoryTransactionCreate", "InventoryTransactionUpdate",
"Product", "ProductCreate", "ProductUpdate",
"Supplier", "SupplierCreate", "SupplierUpdate",
"Token", "TokenPayload",
"User", "UserCreate", "UserUpdate",
]

49
app/schemas/category.py Normal file
View File

@ -0,0 +1,49 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
# Shared properties
class CategoryBase(BaseModel):
"""
Base schema for category data.
"""
name: Optional[str] = None
description: Optional[str] = None
# Properties to receive via API on creation
class CategoryCreate(CategoryBase):
"""
Schema for creating new categories.
"""
name: str
# Properties to receive via API on update
class CategoryUpdate(CategoryBase):
"""
Schema for updating category data.
"""
pass
class CategoryInDBBase(CategoryBase):
"""
Base schema for category data from the database.
"""
id: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Additional properties to return via API
class Category(CategoryInDBBase):
"""
Schema for category data to return via API.
"""
pass

View File

@ -0,0 +1,58 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
from app.models.inventory_transaction import TransactionType
# Shared properties
class InventoryTransactionBase(BaseModel):
"""
Base schema for inventory transaction data.
"""
transaction_type: Optional[TransactionType] = None
quantity: Optional[int] = None
transaction_date: Optional[datetime] = None
notes: Optional[str] = None
unit_price: Optional[float] = None
reference_number: Optional[str] = None
product_id: Optional[str] = None
# Properties to receive via API on creation
class InventoryTransactionCreate(InventoryTransactionBase):
"""
Schema for creating new inventory transactions.
"""
transaction_type: TransactionType
quantity: int
product_id: str
transaction_date: datetime = Field(default_factory=datetime.utcnow)
# Properties to receive via API on update
class InventoryTransactionUpdate(InventoryTransactionBase):
"""
Schema for updating inventory transaction data.
"""
pass
class InventoryTransactionInDBBase(InventoryTransactionBase):
"""
Base schema for inventory transaction data from the database.
"""
id: str
created_at: datetime
class Config:
from_attributes = True
# Additional properties to return via API
class InventoryTransaction(InventoryTransactionInDBBase):
"""
Schema for inventory transaction data to return via API.
"""
pass

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

@ -0,0 +1,78 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
# Shared properties
class ProductBase(BaseModel):
"""
Base schema for product data.
"""
name: Optional[str] = None
description: Optional[str] = None
sku: Optional[str] = None
price: Optional[float] = None
cost: Optional[float] = None
quantity: Optional[int] = None
reorder_level: Optional[int] = None
category_id: Optional[str] = None
supplier_id: Optional[str] = None
# Properties to receive via API on creation
class ProductCreate(ProductBase):
"""
Schema for creating new products.
"""
name: str
sku: str
price: float
cost: float
quantity: int = 0
reorder_level: int = 10
# Properties to receive via API on update
class ProductUpdate(ProductBase):
"""
Schema for updating product data.
"""
pass
class ProductInDBBase(ProductBase):
"""
Base schema for product data from the database.
"""
id: str
owner_id: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Additional properties to return via API
class Product(ProductInDBBase):
"""
Schema for product data to return via API.
"""
pass
# Additional properties for inventory status
class ProductInventoryStatus(BaseModel):
"""
Schema for product inventory status.
"""
id: str
name: str
sku: str
quantity: int
reorder_level: int
status: str # "In Stock", "Low Stock", "Out of Stock"
class Config:
from_attributes = True

53
app/schemas/supplier.py Normal file
View File

@ -0,0 +1,53 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
# Shared properties
class SupplierBase(BaseModel):
"""
Base schema for supplier data.
"""
name: Optional[str] = None
contact_name: Optional[str] = None
email: Optional[EmailStr] = None
phone: Optional[str] = None
address: Optional[str] = None
notes: Optional[str] = None
# Properties to receive via API on creation
class SupplierCreate(SupplierBase):
"""
Schema for creating new suppliers.
"""
name: str
# Properties to receive via API on update
class SupplierUpdate(SupplierBase):
"""
Schema for updating supplier data.
"""
pass
class SupplierInDBBase(SupplierBase):
"""
Base schema for supplier data from the database.
"""
id: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Additional properties to return via API
class Supplier(SupplierInDBBase):
"""
Schema for supplier data to return via API.
"""
pass

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

@ -0,0 +1,18 @@
from typing import Optional
from pydantic import BaseModel
class Token(BaseModel):
"""
Schema for authentication token response.
"""
access_token: str
token_type: str
class TokenPayload(BaseModel):
"""
Schema for JWT token payload.
"""
sub: Optional[str] = None

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

@ -0,0 +1,60 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
# Shared properties
class UserBase(BaseModel):
"""
Base schema for user data.
"""
email: Optional[EmailStr] = None
full_name: Optional[str] = None
is_active: Optional[bool] = True
is_superuser: bool = False
# Properties to receive via API on creation
class UserCreate(UserBase):
"""
Schema for creating new users.
"""
email: EmailStr
password: str
# Properties to receive via API on update
class UserUpdate(UserBase):
"""
Schema for updating user data.
"""
password: Optional[str] = None
class UserInDBBase(UserBase):
"""
Base schema for user data from the database.
"""
id: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Additional properties to return via API
class User(UserInDBBase):
"""
Schema for user data to return via API.
"""
pass
# Additional properties stored in DB
class UserInDB(UserInDBBase):
"""
Schema for user data stored in the database.
"""
hashed_password: str

49
main.py Normal file
View File

@ -0,0 +1,49 @@
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,
openapi_url="/openapi.json",
version=settings.VERSION,
description=settings.DESCRIPTION,
)
# Set up CORS
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 that returns basic application information.
"""
return {
"title": settings.PROJECT_NAME,
"description": settings.DESCRIPTION,
"version": settings.VERSION,
"docs_url": "/docs",
"health_check": "/health",
}
@app.get("/health", status_code=200)
async def health_check():
"""
Health check endpoint to verify the service is up and running.
"""
return {
"status": "healthy",
"version": settings.VERSION,
}

80
migrations/env.py Normal file
View File

@ -0,0 +1,80 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.db.base import Base # noqa - imported for alembic autogenerate
# 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)
# 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 = settings.SQLALCHEMY_DATABASE_URL
context.configure(
url=url,
target_metadata=Base.metadata,
literal_binds=True,
compare_type=True,
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.
"""
configuration = config.get_section(config.config_ini_section)
configuration["sqlalchemy.url"] = settings.SQLALCHEMY_DATABASE_URL
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
is_sqlite = connection.dialect.name == "sqlite"
context.configure(
connection=connection,
target_metadata=Base.metadata,
compare_type=True,
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():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,114 @@
"""Initial migration
Revision ID: aaa1bc2a6d3c
Revises:
Create Date: 2023-11-16 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = 'aaa1bc2a6d3c'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Create user table
op.create_table(
'user',
sa.Column('id', sa.String(), primary_key=True, index=True),
sa.Column('email', sa.String(), nullable=False, unique=True, index=True),
sa.Column('full_name', sa.String(), nullable=True),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('is_active', sa.Boolean(), default=True),
sa.Column('is_superuser', sa.Boolean(), default=False),
sa.Column('created_at', sa.DateTime(), default=sa.func.current_timestamp()),
sa.Column(
'updated_at',
sa.DateTime(),
default=sa.func.current_timestamp(),
onupdate=sa.func.current_timestamp()
),
)
# Create category table
op.create_table(
'category',
sa.Column('id', sa.String(), primary_key=True, index=True),
sa.Column('name', sa.String(), nullable=False, index=True),
sa.Column('description', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), default=sa.func.current_timestamp()),
sa.Column(
'updated_at',
sa.DateTime(),
default=sa.func.current_timestamp(),
onupdate=sa.func.current_timestamp()
),
)
# Create supplier table
op.create_table(
'supplier',
sa.Column('id', sa.String(), primary_key=True, index=True),
sa.Column('name', sa.String(), nullable=False, index=True),
sa.Column('contact_name', sa.String(), nullable=True),
sa.Column('email', sa.String(), nullable=True),
sa.Column('phone', sa.String(), nullable=True),
sa.Column('address', sa.String(), nullable=True),
sa.Column('notes', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), default=sa.func.current_timestamp()),
sa.Column(
'updated_at',
sa.DateTime(),
default=sa.func.current_timestamp(),
onupdate=sa.func.current_timestamp()
),
)
# Create product table
op.create_table(
'product',
sa.Column('id', sa.String(), primary_key=True, index=True),
sa.Column('name', sa.String(), nullable=False, index=True),
sa.Column('description', sa.String(), nullable=True),
sa.Column('sku', sa.String(), nullable=False, unique=True, index=True),
sa.Column('price', sa.Float(), nullable=False),
sa.Column('cost', sa.Float(), nullable=False),
sa.Column('quantity', sa.Integer(), default=0),
sa.Column('reorder_level', sa.Integer(), default=10),
sa.Column('category_id', sa.String(), sa.ForeignKey('category.id'), nullable=True),
sa.Column('supplier_id', sa.String(), sa.ForeignKey('supplier.id'), nullable=True),
sa.Column('owner_id', sa.String(), sa.ForeignKey('user.id'), nullable=False),
sa.Column('created_at', sa.DateTime(), default=sa.func.current_timestamp()),
sa.Column(
'updated_at',
sa.DateTime(),
default=sa.func.current_timestamp(),
onupdate=sa.func.current_timestamp()
),
)
# Create inventory_transaction table
op.create_table(
'inventorytransaction',
sa.Column('id', sa.String(), primary_key=True, index=True),
sa.Column('transaction_type', sa.String(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('transaction_date', sa.DateTime(), default=sa.func.current_timestamp()),
sa.Column('notes', sa.String(), nullable=True),
sa.Column('unit_price', sa.Float(), nullable=True),
sa.Column('reference_number', sa.String(), nullable=True),
sa.Column('product_id', sa.String(), sa.ForeignKey('product.id'), nullable=False),
sa.Column('created_at', sa.DateTime(), default=sa.func.current_timestamp()),
)
def downgrade():
op.drop_table('inventorytransaction')
op.drop_table('product')
op.drop_table('supplier')
op.drop_table('category')
op.drop_table('user')

13
pyproject.toml Normal file
View File

@ -0,0 +1,13 @@
[tool.ruff]
line-length = 100
target-version = "py38"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes
"I", # isort
]
[tool.ruff.lint.isort]
known-third-party = ["fastapi", "pydantic", "starlette", "sqlalchemy"]

13
requirements.txt Normal file
View File

@ -0,0 +1,13 @@
fastapi>=0.103.1
uvicorn>=0.23.2
sqlalchemy>=2.0.20
pydantic>=2.4.2
pydantic-settings>=2.0.3
alembic>=1.12.0
python-jose>=3.3.0
passlib>=1.7.4
python-multipart>=0.0.6
ruff>=0.0.290
bcrypt>=4.0.1
email-validator>=2.0.0
python-dotenv>=1.0.0