Implement FastAPI-based Small Business Inventory Management System

This commit is contained in:
Automated Action 2025-06-17 13:45:31 +00:00
parent 05cef3bdf3
commit a090657a76
46 changed files with 2870 additions and 2 deletions

158
README.md
View File

@ -1,3 +1,157 @@
# FastAPI Application # Small Business Inventory Management System
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. A FastAPI-based inventory management system designed for small businesses. This API provides comprehensive inventory management capabilities including product management, inventory movement tracking, supplier and category organization, user authentication, and reporting.
## Features
- **Product Management**: Create, update, and delete products with details such as SKU, barcode, price, and stock levels
- **Category & Supplier Management**: Organize products by categories and suppliers
- **Inventory Movement Tracking**: Track all inventory changes (stock in, stock out, adjustments, returns)
- **User Authentication**: Secure API access with JWT-based authentication
- **Reports**: Generate inventory insights including low stock alerts, inventory valuation, and movement history
- **Health Monitoring**: Monitor API and database health
## Tech Stack
- **Framework**: FastAPI
- **Database**: SQLite with SQLAlchemy ORM
- **Migrations**: Alembic
- **Authentication**: JWT tokens with OAuth2
- **Validation**: Pydantic models
## Getting Started
### Prerequisites
- Python 3.8+
- pip (Python package manager)
### Installation
1. Clone the repository:
```
git clone https://github.com/yourusername/smallbusinessinventorymanagementsystem.git
cd smallbusinessinventorymanagementsystem
```
2. Create a virtual environment:
```
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. Install dependencies:
```
pip install -r requirements.txt
```
4. Initialize the database:
```
alembic upgrade head
```
5. Run the application:
```
uvicorn main:app --reload
```
The API will be available at http://localhost:8000
### Environment Variables
The application uses the following environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| SECRET_KEY | Secret key for JWT token generation | *Random generated key* |
| ACCESS_TOKEN_EXPIRE_MINUTES | JWT token expiration time in minutes | 11520 (8 days) |
| DEBUG | Enable debug mode | False |
| ENVIRONMENT | Environment name (development, production) | development |
## API Documentation
Once the application is running, you can access the interactive API documentation at:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## API Endpoints
The API is organized around the following resources:
### Authentication
- `POST /api/v1/auth/login/access-token` - Get access token
- `POST /api/v1/auth/login/test-token` - Test access token
### Users
- `GET /api/v1/users` - List all users (admin only)
- `POST /api/v1/users` - Create new user (admin only)
- `GET /api/v1/users/me` - Get current user
- `PUT /api/v1/users/me` - Update current user
- `GET /api/v1/users/{user_id}` - Get user by ID (admin only)
- `PUT /api/v1/users/{user_id}` - Update user (admin only)
### Products
- `GET /api/v1/products` - List all products
- `POST /api/v1/products` - Create new product
- `GET /api/v1/products/{product_id}` - Get product by ID
- `PUT /api/v1/products/{product_id}` - Update product
- `DELETE /api/v1/products/{product_id}` - Delete product
- `PATCH /api/v1/products/{product_id}/stock` - Update product stock
### Categories
- `GET /api/v1/categories` - List all categories
- `POST /api/v1/categories` - Create new category
- `GET /api/v1/categories/{category_id}` - Get category by ID
- `PUT /api/v1/categories/{category_id}` - Update category
- `DELETE /api/v1/categories/{category_id}` - Delete category
### Suppliers
- `GET /api/v1/suppliers` - List all suppliers
- `POST /api/v1/suppliers` - Create new supplier
- `GET /api/v1/suppliers/{supplier_id}` - Get supplier by ID
- `PUT /api/v1/suppliers/{supplier_id}` - Update supplier
- `DELETE /api/v1/suppliers/{supplier_id}` - Delete supplier
### Inventory Movements
- `GET /api/v1/inventory/movements` - List inventory movements
- `POST /api/v1/inventory/movements` - Create inventory movement
- `GET /api/v1/inventory/movements/{movement_id}` - Get movement by ID
- `DELETE /api/v1/inventory/movements/{movement_id}` - Delete movement
### Reports
- `GET /api/v1/reports/low-stock` - Get low stock products
- `GET /api/v1/reports/inventory-value` - Get inventory value
- `GET /api/v1/reports/movement-summary` - Get movement summary
### Health Check
- `GET /api/v1/health` - Check API health
## Default Admin User
On first run, the system creates a default admin user:
- Email: admin@example.com
- Password: admin
**Important:** Change the default admin password in production environments.
## Development
### Running Tests
```
# TODO: Add test commands
```
### Code Formatting
The project uses Ruff for code formatting and linting:
```
ruff check .
ruff format .
```
## 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
# SQLite URL example
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

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

@ -0,0 +1,48 @@
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/access-token")
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(reusable_oauth2)
) -> models.User:
"""
Validate the token and return the current user.
"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[security.ALGORITHM])
token_data = schemas.TokenPayload(**payload)
except (jwt.JWTError, ValidationError) as e:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
) from e
user = crud.user.get(db, user_id=token_data.sub)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not crud.user.is_active(user):
raise HTTPException(status_code=400, detail="Inactive user")
return user
def get_current_active_superuser(
current_user: models.User = Depends(get_current_user),
) -> models.User:
"""
Validate that the current user is a superuser.
"""
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 current_user

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

@ -0,0 +1,24 @@
from fastapi import APIRouter
from app.api.v1.endpoints import (
auth,
categories,
health,
inventory,
products,
reports,
suppliers,
users,
)
api_router = APIRouter()
# Include routers from endpoints
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(products.router, prefix="/products", tags=["products"])
api_router.include_router(categories.router, prefix="/categories", tags=["categories"])
api_router.include_router(suppliers.router, prefix="/suppliers", tags=["suppliers"])
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
api_router.include_router(reports.router, prefix="/reports", tags=["reports"])

View File

@ -0,0 +1,43 @@
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, models, schemas
from app.api.deps import get_current_user, get_db
from app.core import security
from app.core.config import settings
router = APIRouter()
@router.post("/login/access-token", response_model=schemas.Token)
def login_access_token(
db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests
"""
user = crud.user.authenticate(db, email=form_data.username, password=form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
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("/login/test-token", response_model=schemas.User)
def test_token(current_user: models.User = Depends(get_current_user)) -> Any:
"""
Test access token
"""
return current_user

View File

@ -0,0 +1,119 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, schemas
from app.db.session import get_db
router = APIRouter()
@router.get("/", response_model=List[schemas.Category])
def read_categories(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
name: Optional[str] = None,
) -> Any:
"""
Retrieve categories with optional filtering by name.
"""
categories = crud.category.get_multi(db, skip=skip, limit=limit, name=name)
return categories
@router.post("/", response_model=schemas.Category, status_code=status.HTTP_201_CREATED)
def create_category(
*,
db: Session = Depends(get_db),
category_in: schemas.CategoryCreate,
) -> Any:
"""
Create new category.
"""
# Check if category with the same name already exists
category = crud.category.get_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.category.create(db, obj_in=category_in)
return category
@router.get("/{category_id}", response_model=schemas.Category)
def read_category(
*,
db: Session = Depends(get_db),
category_id: str,
) -> Any:
"""
Get category by ID.
"""
category = crud.category.get(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("/{category_id}", response_model=schemas.Category)
def update_category(
*,
db: Session = Depends(get_db),
category_id: str,
category_in: schemas.CategoryUpdate,
) -> Any:
"""
Update a category.
"""
category = crud.category.get(db, category_id=category_id)
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found",
)
# Check if trying to update to existing name
if category_in.name and category_in.name != category.name:
existing_category = crud.category.get_by_name(db, name=category_in.name)
if existing_category and existing_category.id != category_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Category with this name already exists",
)
category = crud.category.update(db, db_obj=category, obj_in=category_in)
return category
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_category(
*,
db: Session = Depends(get_db),
category_id: str,
) -> Any:
"""
Delete a category.
"""
category = crud.category.get(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 before deleting
if category.products and len(category.products) > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete category with products. Remove or reassign products first.",
)
crud.category.remove(db, category_id=category_id)
return None

View File

@ -0,0 +1,45 @@
import os
import time
from typing import Any, Dict
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_db
router = APIRouter()
@router.get("/")
async def health_check(db: Session = Depends(get_db)) -> Dict[str, Any]:
"""
Health check endpoint for the Inventory Management System API.
Checks database connectivity and returns status.
"""
start_time = time.time()
health_data = {
"status": "healthy",
"timestamp": start_time,
"components": {
"database": {"status": "unhealthy", "details": "Could not connect to database"},
"api": {"status": "healthy", "details": "API is operational"},
},
"environment": os.environ.get("ENVIRONMENT", "development"),
}
# Check database connectivity
try:
# Try to execute a simple query to check database connectivity
db.execute("SELECT 1")
health_data["components"]["database"] = {
"status": "healthy",
"details": "Database connection successful",
}
except Exception as e:
health_data["status"] = "unhealthy"
health_data["components"]["database"] = {"status": "unhealthy", "details": str(e)}
# Calculate response time
health_data["response_time_ms"] = round((time.time() - start_time) * 1000, 2)
return health_data

View File

@ -0,0 +1,119 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, schemas
from app.db.session import get_db
from app.models.inventory_movement import MovementType
router = APIRouter()
@router.get("/movements/", response_model=List[schemas.InventoryMovementWithProduct])
def read_inventory_movements(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
product_id: Optional[str] = None,
movement_type: Optional[schemas.MovementTypeEnum] = None,
) -> Any:
"""
Retrieve inventory movements with optional filtering.
"""
if movement_type:
db_movement_type = MovementType(movement_type)
else:
db_movement_type = None
movements = crud.inventory.get_multi(
db, skip=skip, limit=limit, product_id=product_id, movement_type=db_movement_type
)
return movements
@router.post(
"/movements/", response_model=schemas.InventoryMovement, status_code=status.HTTP_201_CREATED
)
def create_inventory_movement(
*,
db: Session = Depends(get_db),
movement_in: schemas.InventoryMovementCreate,
) -> Any:
"""
Create a new inventory movement (stock in, stock out, adjustment, return).
"""
# Check if product exists
product = crud.product.get(db, product_id=movement_in.product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)
# For stock out, check if there is enough stock
if (
movement_in.type == schemas.MovementTypeEnum.STOCK_OUT
and product.current_stock < movement_in.quantity
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Insufficient stock for product {product.name}. Available: {product.current_stock}, Requested: {movement_in.quantity}",
)
try:
movement = crud.inventory.create(db, obj_in=movement_in)
return movement
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
@router.get("/movements/{movement_id}", response_model=schemas.InventoryMovementWithProduct)
def read_inventory_movement(
*,
db: Session = Depends(get_db),
movement_id: str,
) -> Any:
"""
Get a specific inventory movement by ID.
"""
movement = crud.inventory.get(db, movement_id=movement_id)
if not movement:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Inventory movement not found",
)
return movement
@router.delete(
"/movements/{movement_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None
)
def delete_inventory_movement(
*,
db: Session = Depends(get_db),
movement_id: str,
) -> Any:
"""
Delete an inventory movement and revert its effect on stock.
Warning: This will alter inventory history and may cause inconsistencies.
Consider using adjustments instead for corrections.
"""
movement = crud.inventory.get(db, movement_id=movement_id)
if not movement:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Inventory movement not found",
)
try:
crud.inventory.remove(db, movement_id=movement_id)
return None
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e

View File

@ -0,0 +1,186 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, schemas
from app.db.session import get_db
router = APIRouter()
@router.get("/", response_model=List[schemas.Product])
def read_products(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
name: Optional[str] = None,
category_id: Optional[str] = None,
supplier_id: Optional[str] = None,
is_active: Optional[bool] = None,
low_stock: Optional[bool] = None,
) -> Any:
"""
Retrieve products with optional filtering.
"""
filters = {
"name": name,
"category_id": category_id,
"supplier_id": supplier_id,
"is_active": is_active,
"low_stock": low_stock,
}
# Remove None values
filters = {k: v for k, v in filters.items() if v is not None}
products = crud.product.get_multi(db, skip=skip, limit=limit, filters=filters)
return products
@router.post("/", response_model=schemas.Product, status_code=status.HTTP_201_CREATED)
def create_product(
*,
db: Session = Depends(get_db),
product_in: schemas.ProductCreate,
) -> Any:
"""
Create new product.
"""
# Check if product with the same SKU already exists
if product_in.sku:
product = crud.product.get_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 product with the same barcode already exists
if product_in.barcode:
product = crud.product.get_by_barcode(db, barcode=product_in.barcode)
if product:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Product with this barcode already exists.",
)
product = crud.product.create(db, obj_in=product_in)
return product
@router.get("/{product_id}", response_model=schemas.ProductWithDetails)
def read_product(
*,
db: Session = Depends(get_db),
product_id: str,
) -> Any:
"""
Get product by ID.
"""
product = crud.product.get(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(get_db),
product_id: str,
product_in: schemas.ProductUpdate,
) -> Any:
"""
Update a product.
"""
product = crud.product.get(db, product_id=product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)
# Check if trying to update to existing SKU
if product_in.sku and product_in.sku != product.sku:
existing_product = crud.product.get_by_sku(db, sku=product_in.sku)
if existing_product and existing_product.id != product_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Product with this SKU already exists",
)
# Check if trying to update to existing barcode
if product_in.barcode and product_in.barcode != product.barcode:
existing_product = crud.product.get_by_barcode(db, barcode=product_in.barcode)
if existing_product and existing_product.id != product_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Product with this barcode already exists",
)
product = crud.product.update(db, db_obj=product, obj_in=product_in)
return product
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_product(
*,
db: Session = Depends(get_db),
product_id: str,
) -> Any:
"""
Delete a product.
"""
product = crud.product.get(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 movements before deleting
if product.inventory_movements and len(product.inventory_movements) > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete product with inventory movements",
)
crud.product.remove(db, product_id=product_id)
return None
@router.patch("/{product_id}/stock", response_model=schemas.Product)
def update_product_stock(
*,
db: Session = Depends(get_db),
product_id: str,
stock_update: schemas.StockUpdate,
) -> Any:
"""
Manually update product stock.
"""
product = crud.product.get(db, product_id=product_id)
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found",
)
# Create inventory movement record for audit
movement_in = schemas.InventoryMovementCreate(
product_id=product_id,
quantity=stock_update.quantity,
type=schemas.MovementTypeEnum.ADJUSTMENT,
notes=stock_update.notes or "Manual stock adjustment",
)
# This would be inside a transaction in a real-world scenario
# Create the movement record and update stock
crud.inventory.create(db, obj_in=movement_in)
product = crud.product.update_stock(db, db_obj=product, quantity=stock_update.quantity)
return product

View File

@ -0,0 +1,208 @@
from datetime import datetime, timedelta
from typing import Any, List
from fastapi import APIRouter, Depends
from sqlalchemy import and_, func
from sqlalchemy.orm import Session
from app import models, schemas
from app.api import deps
from app.db.session import get_db
from app.models.inventory_movement import MovementType
router = APIRouter()
@router.get("/low-stock", response_model=List[schemas.LowStockProduct])
def get_low_stock_products(
db: Session = Depends(get_db),
current_user: models.User = Depends(deps.get_current_user),
) -> Any:
"""
Get all products that are at or below their minimum stock level.
"""
# Query products with supplier and category names
query = (
db.query(
models.Product.id,
models.Product.name,
models.Product.sku,
models.Product.current_stock,
models.Product.min_stock_level,
models.Supplier.name.label("supplier_name"),
models.Category.name.label("category_name"),
)
.outerjoin(models.Supplier, models.Product.supplier_id == models.Supplier.id)
.outerjoin(models.Category, models.Product.category_id == models.Category.id)
.filter(
and_(
models.Product.is_active,
models.Product.min_stock_level.isnot(None),
models.Product.current_stock <= models.Product.min_stock_level,
)
)
.order_by(
# Order by criticality: largest gap between current and min stock first
(models.Product.min_stock_level - models.Product.current_stock).desc()
)
)
# Convert to list of dictionaries
results = [
{
"id": row.id,
"name": row.name,
"sku": row.sku,
"current_stock": row.current_stock,
"min_stock_level": row.min_stock_level,
"supplier_name": row.supplier_name,
"category_name": row.category_name,
}
for row in query.all()
]
return results
@router.get("/inventory-value", response_model=schemas.InventoryValueSummary)
def get_inventory_value(
db: Session = Depends(get_db),
current_user: models.User = Depends(deps.get_current_user),
) -> Any:
"""
Get the total value of the inventory and breakdown by category.
"""
# Query all active products
products = (
db.query(
models.Product.id,
models.Product.name,
models.Product.sku,
models.Product.current_stock,
models.Product.price,
models.Product.category_id,
models.Category.name.label("category_name"),
)
.outerjoin(models.Category, models.Product.category_id == models.Category.id)
.filter(models.Product.is_active)
.all()
)
# Calculate overall inventory value
total_products = len(products)
total_items = sum(p.current_stock for p in products)
total_value = sum(p.current_stock * p.price for p in products)
# Calculate average item value
average_item_value = 0
if total_items > 0:
average_item_value = total_value / total_items
# Group by category
category_values = {}
for p in products:
category_id = p.category_id or "uncategorized"
category_name = p.category_name or "Uncategorized"
if category_id not in category_values:
category_values[category_id] = {
"id": category_id,
"name": category_name,
"product_count": 0,
"total_items": 0,
"total_value": 0.0,
}
category_values[category_id]["product_count"] += 1
category_values[category_id]["total_items"] += p.current_stock
category_values[category_id]["total_value"] += p.current_stock * p.price
return {
"total_products": total_products,
"total_items": total_items,
"total_value": total_value,
"average_item_value": average_item_value,
"by_category": list(category_values.values()),
}
@router.get("/movement-summary", response_model=schemas.MovementSummary)
def get_movement_summary(
period: schemas.TimePeriod = schemas.TimePeriod.MONTH,
db: Session = Depends(get_db),
current_user: models.User = Depends(deps.get_current_user),
) -> Any:
"""
Get a summary of inventory movements for a specific time period.
"""
# Calculate date range based on period
end_date = datetime.utcnow()
if period == schemas.TimePeriod.DAY:
start_date = end_date - timedelta(days=1)
period_name = "Last 24 hours"
elif period == schemas.TimePeriod.WEEK:
start_date = end_date - timedelta(days=7)
period_name = "Last 7 days"
elif period == schemas.TimePeriod.MONTH:
start_date = end_date - timedelta(days=30)
period_name = "Last 30 days"
elif period == schemas.TimePeriod.YEAR:
start_date = end_date - timedelta(days=365)
period_name = "Last 365 days"
else: # ALL
start_date = datetime(1900, 1, 1)
period_name = "All time"
# Query movement statistics
stock_in = (
db.query(func.sum(models.InventoryMovement.quantity))
.filter(
models.InventoryMovement.type == MovementType.STOCK_IN,
models.InventoryMovement.created_at.between(start_date, end_date),
)
.scalar()
or 0
)
stock_out = (
db.query(func.sum(models.InventoryMovement.quantity))
.filter(
models.InventoryMovement.type == MovementType.STOCK_OUT,
models.InventoryMovement.created_at.between(start_date, end_date),
)
.scalar()
or 0
)
adjustments = (
db.query(func.sum(models.InventoryMovement.quantity))
.filter(
models.InventoryMovement.type == MovementType.ADJUSTMENT,
models.InventoryMovement.created_at.between(start_date, end_date),
)
.scalar()
or 0
)
returns = (
db.query(func.sum(models.InventoryMovement.quantity))
.filter(
models.InventoryMovement.type == MovementType.RETURN,
models.InventoryMovement.created_at.between(start_date, end_date),
)
.scalar()
or 0
)
# Calculate net change
net_change = stock_in - stock_out + adjustments + returns
return {
"period": period_name,
"stock_in": int(stock_in),
"stock_out": int(stock_out),
"adjustments": int(adjustments),
"returns": int(returns),
"net_change": int(net_change),
}

View File

@ -0,0 +1,102 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, schemas
from app.db.session import get_db
router = APIRouter()
@router.get("/", response_model=List[schemas.Supplier])
def read_suppliers(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
name: Optional[str] = None,
) -> Any:
"""
Retrieve suppliers with optional filtering by name.
"""
suppliers = crud.supplier.get_multi(db, skip=skip, limit=limit, name=name)
return suppliers
@router.post("/", response_model=schemas.Supplier, status_code=status.HTTP_201_CREATED)
def create_supplier(
*,
db: Session = Depends(get_db),
supplier_in: schemas.SupplierCreate,
) -> Any:
"""
Create new supplier.
"""
supplier = crud.supplier.create(db, obj_in=supplier_in)
return supplier
@router.get("/{supplier_id}", response_model=schemas.Supplier)
def read_supplier(
*,
db: Session = Depends(get_db),
supplier_id: str,
) -> Any:
"""
Get supplier by ID.
"""
supplier = crud.supplier.get(db, supplier_id=supplier_id)
if not supplier:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Supplier not found",
)
return supplier
@router.put("/{supplier_id}", response_model=schemas.Supplier)
def update_supplier(
*,
db: Session = Depends(get_db),
supplier_id: str,
supplier_in: schemas.SupplierUpdate,
) -> Any:
"""
Update a supplier.
"""
supplier = crud.supplier.get(db, supplier_id=supplier_id)
if not supplier:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Supplier not found",
)
supplier = crud.supplier.update(db, db_obj=supplier, obj_in=supplier_in)
return supplier
@router.delete("/{supplier_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None)
def delete_supplier(
*,
db: Session = Depends(get_db),
supplier_id: str,
) -> Any:
"""
Delete a supplier.
"""
supplier = crud.supplier.get(db, supplier_id=supplier_id)
if not supplier:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Supplier not found",
)
# Check if supplier has products before deleting
if supplier.products and len(supplier.products) > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete supplier with products. Remove or reassign products first.",
)
crud.supplier.remove(db, supplier_id=supplier_id)
return None

View File

@ -0,0 +1,127 @@
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
from app.db.session import get_db
router = APIRouter()
@router.get("/", response_model=List[schemas.User])
def read_users(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Retrieve users. Only for superusers.
"""
users = crud.user.get_multi(db, skip=skip, limit=limit)
return users
@router.post("/", response_model=schemas.User, status_code=status.HTTP_201_CREATED)
def create_user(
*,
db: Session = Depends(get_db),
user_in: schemas.UserCreate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Create new user. Only for superusers.
"""
user = crud.user.get_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this email already exists in the system.",
)
user = crud.user.create(db, 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_user),
) -> Any:
"""
Get current user.
"""
return current_user
@router.put("/me", response_model=schemas.User)
def update_user_me(
*,
db: Session = Depends(get_db),
full_name: str = Body(None),
email: EmailStr = Body(None),
password: str = Body(None),
current_user: models.User = Depends(deps.get_current_user),
) -> Any:
"""
Update own user.
"""
current_user_data = jsonable_encoder(current_user)
user_in = schemas.UserUpdate(**current_user_data)
if full_name is not None:
user_in.full_name = full_name
if email is not None:
if email != current_user.email:
# Check if email is already taken
existing_user = crud.user.get_by_email(db, email=email)
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
user_in.email = email
if password is not None:
user_in.password = password
user = crud.user.update(db, db_obj=current_user, obj_in=user_in)
return 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_superuser),
db: Session = Depends(get_db),
) -> Any:
"""
Get a specific user by id. Only for superusers.
"""
user = crud.user.get(db, user_id=user_id)
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(get_db),
user_id: str,
user_in: schemas.UserUpdate,
current_user: models.User = Depends(deps.get_current_active_superuser),
) -> Any:
"""
Update a user. Only for superusers.
"""
user = crud.user.get(db, user_id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
user = crud.user.update(db, db_obj=user, obj_in=user_in)
return user

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

@ -0,0 +1,40 @@
import os
import secrets
from pathlib import Path
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# API configuration
API_V1_STR: str = "/api/v1"
SECRET_KEY: str = os.getenv("SECRET_KEY", secrets.token_urlsafe(32))
ACCESS_TOKEN_EXPIRE_MINUTES: int = int(
os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", 60 * 24 * 8)
) # 8 days
# Project configuration
PROJECT_NAME: str = "Small Business Inventory Management System"
PROJECT_DESCRIPTION: str = "API for managing inventory for small businesses"
PROJECT_VERSION: str = "0.1.0"
# Database configuration
DB_DIR: Path = Path("/app") / "storage" / "db"
@field_validator("DB_DIR")
@classmethod
def create_db_dir(cls, v):
v.mkdir(parents=True, exist_ok=True)
return v
# Debug mode
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
# CORS configuration
BACKEND_CORS_ORIGINS: list[str] = ["*"]
model_config = SettingsConfigDict(case_sensitive=True)
settings = Settings()

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

@ -0,0 +1,38 @@
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
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
def create_access_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""
Create a JWT access token.
"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.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 for storing.
"""
return pwd_context.hash(password)

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

@ -0,0 +1,49 @@
from app.crud.product import ( # noqa
get as get_product,
get_by_sku as get_product_by_sku,
get_by_barcode as get_product_by_barcode,
get_multi as get_products,
create as create_product,
update as update_product,
remove as remove_product,
update_stock as update_product_stock,
)
from app.crud.category import ( # noqa
get as get_category,
get_by_name as get_category_by_name,
get_multi as get_categories,
create as create_category,
update as update_category,
remove as remove_category,
)
from app.crud.supplier import ( # noqa
get as get_supplier,
get_by_name as get_supplier_by_name,
get_multi as get_suppliers,
create as create_supplier,
update as update_supplier,
remove as remove_supplier,
)
from app.crud.inventory import ( # noqa
get as get_inventory_movement,
get_multi as get_inventory_movements,
create as create_inventory_movement,
remove as remove_inventory_movement,
)
from app.crud.user import ( # noqa
get as get_user,
get_by_email as get_user_by_email,
get_multi as get_users,
create as create_user,
update as update_user,
authenticate as authenticate_user,
is_active as is_user_active,
is_superuser as is_user_superuser,
)
# Re-export the module level imports as object-like access
from app.crud import product, category, supplier, inventory, user # noqa

81
app/crud/category.py Normal file
View File

@ -0,0 +1,81 @@
from typing import Any, Dict, List, Optional, Union
from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm import Session
from app.models.category import Category
from app.schemas.category import CategoryCreate, CategoryUpdate
from app.utils.uuid import generate_uuid
def get(db: Session, category_id: str) -> Optional[Category]:
"""
Get a category by ID.
"""
return db.query(Category).filter(Category.id == category_id).first()
def get_by_name(db: Session, name: str) -> Optional[Category]:
"""
Get a category by name.
"""
return db.query(Category).filter(Category.name == name).first()
def get_multi(
db: Session, *, skip: int = 0, limit: int = 100, name: Optional[str] = None
) -> List[Category]:
"""
Get multiple categories with optional filtering by name.
"""
query = db.query(Category)
if name:
query = query.filter(Category.name.ilike(f"%{name}%"))
return query.offset(skip).limit(limit).all()
def create(db: Session, *, obj_in: CategoryCreate) -> Category:
"""
Create a new category.
"""
obj_in_data = jsonable_encoder(obj_in)
db_obj = Category(**obj_in_data, id=generate_uuid())
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
db: Session, *, db_obj: Category, obj_in: Union[CategoryUpdate, Dict[str, Any]]
) -> Category:
"""
Update a category.
"""
obj_data = jsonable_encoder(db_obj)
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
for field in obj_data:
if field in update_data:
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def remove(db: Session, *, category_id: str) -> Optional[Category]:
"""
Delete a category.
"""
obj = db.query(Category).get(category_id)
if obj:
db.delete(obj)
db.commit()
return obj

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

@ -0,0 +1,121 @@
from typing import List, Optional
from fastapi.encoders import jsonable_encoder
from sqlalchemy import desc
from sqlalchemy.orm import Session
from app.models.inventory_movement import InventoryMovement, MovementType
from app.models.product import Product
from app.schemas.inventory import InventoryMovementCreate
from app.utils.uuid import generate_uuid
def get(db: Session, movement_id: str) -> Optional[InventoryMovement]:
"""
Get an inventory movement by ID.
"""
return db.query(InventoryMovement).filter(InventoryMovement.id == movement_id).first()
def get_multi(
db: Session,
*,
skip: int = 0,
limit: int = 100,
product_id: Optional[str] = None,
movement_type: Optional[MovementType] = None,
) -> List[InventoryMovement]:
"""
Get multiple inventory movements with optional filtering.
"""
query = db.query(InventoryMovement)
if product_id:
query = query.filter(InventoryMovement.product_id == product_id)
if movement_type:
query = query.filter(InventoryMovement.type == movement_type)
# Order by most recent first
query = query.order_by(desc(InventoryMovement.created_at))
return query.offset(skip).limit(limit).all()
def create(
db: Session, *, obj_in: InventoryMovementCreate, created_by: Optional[str] = None
) -> InventoryMovement:
"""
Create a new inventory movement and update product stock.
"""
obj_in_data = jsonable_encoder(obj_in)
db_obj = InventoryMovement(**obj_in_data, id=generate_uuid(), created_by=created_by)
# Start transaction
try:
# Add movement record
db.add(db_obj)
# Update product stock based on movement type
product = (
db.query(Product).filter(Product.id == obj_in.product_id).with_for_update().first()
)
if not product:
raise ValueError(f"Product with ID {obj_in.product_id} not found")
if obj_in.type in [MovementType.STOCK_IN, MovementType.RETURN]:
product.current_stock += obj_in.quantity
elif obj_in.type == MovementType.STOCK_OUT:
if product.current_stock < obj_in.quantity:
raise ValueError(
f"Insufficient stock for product {product.name}. Available: {product.current_stock}, Requested: {obj_in.quantity}"
)
product.current_stock -= obj_in.quantity
elif obj_in.type == MovementType.ADJUSTMENT:
# For adjustments, the quantity value is the delta (can be positive or negative)
product.current_stock += obj_in.quantity
db.add(product)
db.commit()
db.refresh(db_obj)
return db_obj
except Exception as e:
db.rollback()
raise e
def remove(db: Session, *, movement_id: str) -> Optional[InventoryMovement]:
"""
Delete an inventory movement and revert the stock change.
This is generally not recommended for production use as it alters inventory history.
"""
movement = db.query(InventoryMovement).filter(InventoryMovement.id == movement_id).first()
if not movement:
return None
# Start transaction
try:
# Get the product and prepare to revert the stock change
product = (
db.query(Product).filter(Product.id == movement.product_id).with_for_update().first()
)
if not product:
raise ValueError(f"Product with ID {movement.product_id} not found")
# Revert the stock change based on movement type
if movement.type in [MovementType.STOCK_IN, MovementType.RETURN]:
product.current_stock -= movement.quantity
elif movement.type == MovementType.STOCK_OUT:
product.current_stock += movement.quantity
elif movement.type == MovementType.ADJUSTMENT:
# For adjustments, reverse the quantity effect
product.current_stock -= movement.quantity
# Delete the movement
db.delete(movement)
db.add(product)
db.commit()
return movement
except Exception as e:
db.rollback()
raise e

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

@ -0,0 +1,109 @@
from typing import Any, Dict, List, Optional, Union
from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm import Session
from app.models.product import Product
from app.schemas.product import ProductCreate, ProductUpdate
from app.utils.uuid import generate_uuid
def get(db: Session, product_id: str) -> Optional[Product]:
"""
Get a product by ID.
"""
return db.query(Product).filter(Product.id == product_id).first()
def get_by_sku(db: Session, sku: str) -> Optional[Product]:
"""
Get a product by SKU.
"""
return db.query(Product).filter(Product.sku == sku).first()
def get_by_barcode(db: Session, barcode: str) -> Optional[Product]:
"""
Get a product by barcode.
"""
return db.query(Product).filter(Product.barcode == barcode).first()
def get_multi(
db: Session, *, skip: int = 0, limit: int = 100, filters: Optional[Dict[str, Any]] = None
) -> List[Product]:
"""
Get multiple products with optional filtering.
"""
query = db.query(Product)
# Apply filters if provided
if filters:
if "name" in filters and filters["name"]:
query = query.filter(Product.name.ilike(f"%{filters['name']}%"))
if "category_id" in filters and filters["category_id"]:
query = query.filter(Product.category_id == filters["category_id"])
if "supplier_id" in filters and filters["supplier_id"]:
query = query.filter(Product.supplier_id == filters["supplier_id"])
if "is_active" in filters and filters["is_active"] is not None:
query = query.filter(Product.is_active == filters["is_active"])
if "low_stock" in filters and filters["low_stock"] is True:
query = query.filter(Product.current_stock <= Product.min_stock_level)
return query.offset(skip).limit(limit).all()
def create(db: Session, *, obj_in: ProductCreate) -> Product:
"""
Create a new product.
"""
obj_in_data = jsonable_encoder(obj_in)
db_obj = Product(**obj_in_data, id=generate_uuid())
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
db: Session, *, db_obj: Product, obj_in: Union[ProductUpdate, Dict[str, Any]]
) -> Product:
"""
Update a product.
"""
obj_data = jsonable_encoder(db_obj)
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
for field in obj_data:
if field in update_data:
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def remove(db: Session, *, product_id: str) -> Optional[Product]:
"""
Delete a product.
"""
obj = db.query(Product).get(product_id)
if obj:
db.delete(obj)
db.commit()
return obj
def update_stock(db: Session, *, db_obj: Product, quantity: int) -> Product:
"""
Update product stock quantity.
"""
db_obj.current_stock += quantity
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj

81
app/crud/supplier.py Normal file
View File

@ -0,0 +1,81 @@
from typing import Any, Dict, List, Optional, Union
from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm import Session
from app.models.supplier import Supplier
from app.schemas.supplier import SupplierCreate, SupplierUpdate
from app.utils.uuid import generate_uuid
def get(db: Session, supplier_id: str) -> Optional[Supplier]:
"""
Get a supplier by ID.
"""
return db.query(Supplier).filter(Supplier.id == supplier_id).first()
def get_by_name(db: Session, name: str) -> Optional[Supplier]:
"""
Get a supplier by name (exact match).
"""
return db.query(Supplier).filter(Supplier.name == name).first()
def get_multi(
db: Session, *, skip: int = 0, limit: int = 100, name: Optional[str] = None
) -> List[Supplier]:
"""
Get multiple suppliers with optional filtering by name.
"""
query = db.query(Supplier)
if name:
query = query.filter(Supplier.name.ilike(f"%{name}%"))
return query.offset(skip).limit(limit).all()
def create(db: Session, *, obj_in: SupplierCreate) -> Supplier:
"""
Create a new supplier.
"""
obj_in_data = jsonable_encoder(obj_in)
db_obj = Supplier(**obj_in_data, id=generate_uuid())
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
db: Session, *, db_obj: Supplier, obj_in: Union[SupplierUpdate, Dict[str, Any]]
) -> Supplier:
"""
Update a supplier.
"""
obj_data = jsonable_encoder(db_obj)
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
for field in obj_data:
if field in update_data:
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def remove(db: Session, *, supplier_id: str) -> Optional[Supplier]:
"""
Delete a supplier.
"""
obj = db.query(Supplier).get(supplier_id)
if obj:
db.delete(obj)
db.commit()
return obj

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

@ -0,0 +1,97 @@
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
from app.schemas.user import UserCreate, UserUpdate
from app.utils.uuid import generate_uuid
def get(db: Session, user_id: str) -> Optional[User]:
"""
Get a user by ID.
"""
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.
"""
return db.query(User).filter(User.email == email).first()
def get_multi(db: Session, *, skip: int = 0, limit: int = 100) -> List[User]:
"""
Get multiple users.
"""
return db.query(User).offset(skip).limit(limit).all()
def create(db: Session, *, obj_in: UserCreate) -> User:
"""
Create a new user.
"""
db_obj = User(
id=generate_uuid(),
email=obj_in.email,
hashed_password=get_password_hash(obj_in.password),
full_name=obj_in.full_name,
is_superuser=obj_in.is_superuser,
is_active=obj_in.is_active,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(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.model_dump(exclude_unset=True)
if "password" in update_data and update_data["password"]:
hashed_password = get_password_hash(update_data["password"])
del update_data["password"]
update_data["hashed_password"] = hashed_password
for field in update_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 authenticate(db: Session, *, email: str, password: str) -> Optional[User]:
"""
Authenticate a user.
"""
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.
"""
return user.is_active
def is_superuser(user: User) -> bool:
"""
Check if a user is a superuser.
"""
return user.is_superuser

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

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

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

@ -0,0 +1,18 @@
import re
from typing import Any
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy.orm import as_declarative
@as_declarative()
class Base:
id: Any
__name__: str
# Generate __tablename__ automatically based on class name
@declared_attr
def __tablename__(self) -> str:
# Convert CamelCase to snake_case
name = re.sub(r"(?<!^)(?=[A-Z])", "_", self.__name__).lower()
return name

8
app/db/base_model.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 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_movement import InventoryMovement # noqa

22
app/db/init_db.py Normal file
View File

@ -0,0 +1,22 @@
from sqlalchemy.orm import Session
from app import crud, schemas
def init_db(db: Session) -> None:
"""
Initialize the database with default data.
"""
# Create default admin user if it doesn't exist
user = crud.user.get_by_email(db, email="admin@example.com")
if not user:
user_in = schemas.UserCreate(
email="admin@example.com",
password="admin",
full_name="Default Admin",
is_superuser=True,
)
user = crud.user.create(db, obj_in=user_in)
# Add other default data as needed
pass

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

@ -0,0 +1,25 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import settings
# Ensure DB directory exists
DB_DIR = settings.DB_DIR
DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db() -> Session:
"""
Dependency for getting a database session.
"""
db = SessionLocal()
try:
yield db
finally:
db.close()

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

@ -0,0 +1,5 @@
from app.models.category import Category
from app.models.inventory_movement import InventoryMovement, MovementType
from app.models.product import Product
from app.models.supplier import Supplier
from app.models.user import User

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

@ -0,0 +1,18 @@
from sqlalchemy import Column, DateTime, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base import Base
class Category(Base):
__tablename__ = "categories"
id = Column(String, primary_key=True, index=True)
name = Column(String, unique=True, index=True, nullable=False)
description = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
products = relationship("Product", back_populates="category")

View File

@ -0,0 +1,31 @@
import enum
from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base import Base
class MovementType(str, enum.Enum):
STOCK_IN = "STOCK_IN"
STOCK_OUT = "STOCK_OUT"
ADJUSTMENT = "ADJUSTMENT"
RETURN = "RETURN"
class InventoryMovement(Base):
__tablename__ = "inventory_movements"
id = Column(String, primary_key=True, index=True)
product_id = Column(String, ForeignKey("products.id"), nullable=False)
quantity = Column(Integer, nullable=False)
type = Column(Enum(MovementType), nullable=False)
reference = Column(String, nullable=True)
notes = Column(Text, nullable=True)
unit_price = Column(Float, nullable=True)
created_by = Column(String, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
product = relationship("Product", back_populates="inventory_movements")

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

@ -0,0 +1,39 @@
from sqlalchemy import (
Boolean,
Column,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base import Base
class Product(Base):
__tablename__ = "products"
id = Column(String, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
description = Column(Text, nullable=True)
sku = Column(String, unique=True, index=True, nullable=True)
barcode = Column(String, unique=True, index=True, nullable=True)
price = Column(Float, nullable=False, default=0.0)
cost_price = Column(Float, nullable=True)
current_stock = Column(Integer, nullable=False, default=0)
min_stock_level = Column(Integer, nullable=True)
max_stock_level = Column(Integer, nullable=True)
is_active = Column(Boolean, default=True)
category_id = Column(String, ForeignKey("categories.id"), nullable=True)
supplier_id = Column(String, ForeignKey("suppliers.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
category = relationship("Category", back_populates="products")
supplier = relationship("Supplier", back_populates="products")
inventory_movements = relationship("InventoryMovement", back_populates="product")

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

@ -0,0 +1,21 @@
from sqlalchemy import Column, DateTime, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base import Base
class Supplier(Base):
__tablename__ = "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(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
products = relationship("Product", back_populates="supplier")

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

@ -0,0 +1,17 @@
from sqlalchemy import Boolean, Column, DateTime, String
from sqlalchemy.sql import func
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

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

@ -0,0 +1,24 @@
from app.schemas.category import Category, CategoryCreate, CategoryUpdate
from app.schemas.inventory import (
InventoryMovement,
InventoryMovementCreate,
InventoryMovementWithProduct,
MovementTypeEnum,
)
from app.schemas.product import (
Product,
ProductCreate,
ProductUpdate,
ProductWithDetails,
StockUpdate,
)
from app.schemas.reports import (
CategoryValue,
InventoryValueSummary,
LowStockProduct,
MovementSummary,
ProductValue,
TimePeriod,
)
from app.schemas.supplier import Supplier, SupplierCreate, SupplierUpdate
from app.schemas.user import Token, TokenPayload, User, UserCreate, UserUpdate

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

@ -0,0 +1,36 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
# Shared properties
class CategoryBase(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
# Properties to receive via API on creation
class CategoryCreate(CategoryBase):
name: str
# Properties to receive via API on update
class CategoryUpdate(CategoryBase):
pass
# Properties to return via API
class CategoryInDB(CategoryBase):
id: str
name: str
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Additional properties to return via API
class Category(CategoryInDB):
pass

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

@ -0,0 +1,75 @@
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel
class MovementTypeEnum(str, Enum):
STOCK_IN = "STOCK_IN"
STOCK_OUT = "STOCK_OUT"
ADJUSTMENT = "ADJUSTMENT"
RETURN = "RETURN"
# Shared properties
class InventoryMovementBase(BaseModel):
product_id: Optional[str] = None
quantity: Optional[int] = None
type: Optional[MovementTypeEnum] = None
reference: Optional[str] = None
notes: Optional[str] = None
unit_price: Optional[float] = None
# Properties to receive via API on creation
class InventoryMovementCreate(InventoryMovementBase):
product_id: str
quantity: int
type: MovementTypeEnum
# Properties to receive via API on update
class InventoryMovementUpdate(InventoryMovementBase):
pass
# Properties to return via API
class InventoryMovementInDB(InventoryMovementBase):
id: str
product_id: str
quantity: int
type: MovementTypeEnum
created_by: Optional[str] = None
created_at: datetime
class Config:
from_attributes = True
# Additional properties to return via API
class InventoryMovement(InventoryMovementInDB):
pass
# Inventory movement with product details
class InventoryMovementWithProduct(InventoryMovement):
product: "ProductMinimal"
class Config:
from_attributes = True
# Minimal product representation for movement response
class ProductMinimal(BaseModel):
id: str
name: str
sku: Optional[str] = None
current_stock: int
class Config:
from_attributes = True
# Update recursive forward references
InventoryMovementWithProduct.model_rebuild()

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

@ -0,0 +1,84 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
# Shared properties
class ProductBase(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
sku: Optional[str] = None
barcode: Optional[str] = None
price: Optional[float] = None
cost_price: Optional[float] = None
min_stock_level: Optional[int] = None
max_stock_level: Optional[int] = None
is_active: Optional[bool] = True
category_id: Optional[str] = None
supplier_id: Optional[str] = None
# Properties to receive via API on creation
class ProductCreate(ProductBase):
name: str
price: float
# Properties to receive via API on update
class ProductUpdate(ProductBase):
pass
# Properties for stock update
class StockUpdate(BaseModel):
quantity: int
notes: Optional[str] = None
# Properties to return via API
class ProductInDB(ProductBase):
id: str
name: str
current_stock: int
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Additional properties to return via API
class Product(ProductInDB):
pass
# Properties for product with category and supplier details
class ProductWithDetails(Product):
category: Optional["CategoryMinimal"] = None
supplier: Optional["SupplierMinimal"] = None
class Config:
from_attributes = True
# Minimal category representation for product response
class CategoryMinimal(BaseModel):
id: str
name: str
class Config:
from_attributes = True
# Minimal supplier representation for product response
class SupplierMinimal(BaseModel):
id: str
name: str
class Config:
from_attributes = True
# Update recursive forward references
ProductWithDetails.model_rebuild()

75
app/schemas/reports.py Normal file
View File

@ -0,0 +1,75 @@
from enum import Enum
from typing import List, Optional
from pydantic import BaseModel
# Low stock product model
class LowStockProduct(BaseModel):
id: str
name: str
sku: Optional[str] = None
current_stock: int
min_stock_level: Optional[int] = None
supplier_name: Optional[str] = None
category_name: Optional[str] = None
class Config:
from_attributes = True
# Product value model
class ProductValue(BaseModel):
id: str
name: str
sku: Optional[str] = None
current_stock: int
price: float
total_value: float
class Config:
from_attributes = True
# Inventory value summary
class InventoryValueSummary(BaseModel):
total_products: int
total_items: int
total_value: float
average_item_value: float
by_category: List["CategoryValue"] = []
# Category value model
class CategoryValue(BaseModel):
id: str
name: str
product_count: int
total_items: int
total_value: float
class Config:
from_attributes = True
# Time period enum for inventory movement reports
class TimePeriod(str, Enum):
DAY = "day"
WEEK = "week"
MONTH = "month"
YEAR = "year"
ALL = "all"
# Inventory movement summary model
class MovementSummary(BaseModel):
period: str
stock_in: int
stock_out: int
adjustments: int
returns: int
net_change: int
# Update recursive references
InventoryValueSummary.model_rebuild()

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

@ -0,0 +1,39 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
# Shared properties
class SupplierBase(BaseModel):
name: Optional[str] = None
contact_name: Optional[str] = None
email: Optional[EmailStr] = None
phone: Optional[str] = None
address: Optional[str] = None
# Properties to receive via API on creation
class SupplierCreate(SupplierBase):
name: str
# Properties to receive via API on update
class SupplierUpdate(SupplierBase):
pass
# Properties to return via API
class SupplierInDB(SupplierBase):
id: str
name: str
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Additional properties to return via API
class Supplier(SupplierInDB):
pass

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

@ -0,0 +1,49 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
# Shared properties
class UserBase(BaseModel):
email: Optional[EmailStr] = None
is_active: Optional[bool] = True
is_superuser: Optional[bool] = False
full_name: Optional[str] = None
# Properties to receive via API on creation
class UserCreate(UserBase):
email: EmailStr
password: str
# Properties to receive via API on update
class UserUpdate(UserBase):
password: Optional[str] = None
# Properties to return via API
class UserInDB(UserBase):
id: str
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Additional properties to return via API
class User(UserInDB):
pass
# Properties for token
class Token(BaseModel):
access_token: str
token_type: str
class TokenPayload(BaseModel):
sub: Optional[str] = None
exp: Optional[int] = None

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

@ -0,0 +1 @@
from app.utils.uuid import generate_uuid

6
app/utils/uuid.py Normal file
View File

@ -0,0 +1,6 @@
import uuid
def generate_uuid() -> str:
"""Generate a random UUID and return it as a string."""
return str(uuid.uuid4())

45
main.py Normal file
View File

@ -0,0 +1,45 @@
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.api import api_router
from app.core.config import settings
app = FastAPI(
title=settings.PROJECT_NAME,
description=settings.PROJECT_DESCRIPTION,
version=settings.PROJECT_VERSION,
openapi_url="/openapi.json",
docs_url="/docs",
redoc_url="/redoc",
)
# Add CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(api_router, prefix=settings.API_V1_STR)
# Root endpoint
@app.get("/")
async def root():
"""
Root endpoint for the Inventory Management System API.
"""
return {
"title": settings.PROJECT_NAME,
"description": settings.PROJECT_DESCRIPTION,
"docs": f"{settings.API_V1_STR}/docs",
"health": f"{settings.API_V1_STR}/health",
}
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

89
migrations/env.py Normal file
View File

@ -0,0 +1,89 @@
import os
import sys
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
# Add the project directory to the Python path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# Import the SQLAlchemy models
from app.core.config import settings
from app.db.base_model 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)
# Set the SQLAlchemy URL
config.set_main_option("sqlalchemy.url", str(settings.DB_DIR).replace("\\", "/"))
# 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, # Use batch mode for SQLite
)
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:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=connection.dialect.name == "sqlite", # Use batch mode for 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,162 @@
"""Initial database structure
Revision ID: 0001
Revises:
Create Date: 2023-11-18
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "0001"
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("hashed_password", sa.String(), nullable=False),
sa.Column("full_name", sa.String(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=True, default=True),
sa.Column("is_superuser", sa.Boolean(), nullable=True, default=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)")
),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
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)
# 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)")
),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
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 suppliers table
op.create_table(
"suppliers",
sa.Column("id", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
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.Text(), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)")
),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_suppliers_id"), "suppliers", ["id"], unique=False)
op.create_index(op.f("ix_suppliers_name"), "suppliers", ["name"], unique=False)
# 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("sku", sa.String(), nullable=True),
sa.Column("barcode", sa.String(), nullable=True),
sa.Column("price", sa.Float(), nullable=False, default=0.0),
sa.Column("cost_price", sa.Float(), nullable=True),
sa.Column("current_stock", sa.Integer(), nullable=False, default=0),
sa.Column("min_stock_level", sa.Integer(), nullable=True),
sa.Column("max_stock_level", sa.Integer(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=True, default=True),
sa.Column("category_id", sa.String(), nullable=True),
sa.Column("supplier_id", sa.String(), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)")
),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["category_id"],
["categories.id"],
),
sa.ForeignKeyConstraint(
["supplier_id"],
["suppliers.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_products_barcode"), "products", ["barcode"], unique=True)
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_movements table
op.create_table(
"inventory_movements",
sa.Column("id", sa.String(), nullable=False),
sa.Column("product_id", sa.String(), nullable=False),
sa.Column("quantity", sa.Integer(), nullable=False),
sa.Column(
"type",
sa.Enum("STOCK_IN", "STOCK_OUT", "ADJUSTMENT", "RETURN", name="movementtype"),
nullable=False,
),
sa.Column("reference", sa.String(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("unit_price", sa.Float(), nullable=True),
sa.Column("created_by", sa.String(), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)")
),
sa.ForeignKeyConstraint(
["created_by"],
["users.id"],
),
sa.ForeignKeyConstraint(
["product_id"],
["products.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_inventory_movements_id"), "inventory_movements", ["id"], unique=False)
def downgrade() -> None:
# Drop inventory_movements table
op.drop_index(op.f("ix_inventory_movements_id"), table_name="inventory_movements")
op.drop_table("inventory_movements")
# Drop products table
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_index(op.f("ix_products_barcode"), table_name="products")
op.drop_table("products")
# Drop suppliers table
op.drop_index(op.f("ix_suppliers_name"), table_name="suppliers")
op.drop_index(op.f("ix_suppliers_id"), table_name="suppliers")
op.drop_table("suppliers")
# Drop categories table
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")
# Drop users table
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")

45
pyproject.toml Normal file
View File

@ -0,0 +1,45 @@
[build-system]
requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"
[tool.ruff]
# Allow lines to be as long as 100 characters
line-length = 100
# Assume Python 3.8
target-version = "py38"
[tool.ruff.lint]
# Enable pycodestyle (E), Pyflakes (F), isort (I), and more
select = ["E", "F", "I", "N", "W", "C90", "B", "UP"]
# Ignore specific rules:
# B008: Do not perform function call `Depends` in argument defaults
# E501: Line too long
# F401: Imported but unused
ignore = ["B008", "E501", "F401"]
# Allow autofix for all enabled rules (when `--fix`) is provided
fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"]
unfixable = []
# Exclude a variety of commonly ignored directories
exclude = [
".git",
".ruff_cache",
".venv",
"venv",
"__pypackages__",
"migrations/versions",
"dist",
"node_modules",
]
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[tool.ruff.lint.mccabe]
# Flag functions with a complexity higher than 15
max-complexity = 15
[tool.ruff.lint.isort]
known-third-party = ["fastapi", "pydantic", "sqlalchemy", "alembic"]
section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"]

13
requirements.txt Normal file
View File

@ -0,0 +1,13 @@
fastapi>=0.104.0
uvicorn>=0.23.0
sqlalchemy>=2.0.0
alembic>=1.12.0
pydantic>=2.4.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
httpx>=0.25.0
python-dotenv>=1.0.0
ruff>=0.1.0