Create Small Business Inventory Management System with FastAPI and SQLite

- Set up project structure and FastAPI application
- Create database models with SQLAlchemy
- Implement authentication with JWT
- Add CRUD operations for products, inventory, categories
- Implement purchase order and sales functionality
- Create reporting endpoints
- Set up Alembic for database migrations
- Add comprehensive documentation in README.md
This commit is contained in:
Automated Action 2025-05-16 08:53:15 +00:00
parent 9886da8c71
commit 5935f302dc
49 changed files with 3404 additions and 2 deletions

117
README.md
View File

@ -1,3 +1,116 @@
# FastAPI Application # Small Business Inventory Management System
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. A comprehensive inventory management system for small businesses built with FastAPI and SQLite.
## Features
- **Product Management**: Create, update, delete, and search products with SKU, barcode support
- **Inventory Tracking**: Track inventory levels across multiple locations
- **Purchase Order Management**: Create purchase orders and receive inventory
- **Sales Tracking**: Record sales and automatically update inventory
- **User Authentication**: Secure API with JWT authentication
- **Role-Based Access Control**: Admin and regular user roles
- **Reporting**: Several reports including inventory value, low stock, sales summary, and purchase summary
## Technologies
- **Backend**: FastAPI (Python 3.9+)
- **Database**: SQLite with SQLAlchemy ORM
- **Authentication**: JWT tokens with OAuth2
- **Migration**: Alembic for database migrations
- **Validation**: Pydantic for data validation
- **Linting**: Ruff for code quality
## Setup and Installation
1. Clone the repository
2. Install dependencies:
```
pip install -r requirements.txt
```
3. Run the database migrations:
```
alembic upgrade head
```
4. Start the application:
```
python run.py
```
The API will be available at http://localhost:8000
## API Documentation
Once the application is running, you can access:
- Interactive API documentation: http://localhost:8000/docs
- Alternative API documentation: http://localhost:8000/redoc
## Default Admin User
The system comes with a default admin user:
- Email: admin@example.com
- Password: admin123
**Important**: Change the default password after first login!
## Directory Structure
```
├── app # Application source code
│ ├── api # API endpoints
│ ├── core # Core functionality
│ ├── crud # CRUD operations
│ ├── db # Database setup
│ ├── models # SQLAlchemy models
│ └── schemas # Pydantic schemas
├── migrations # Alembic migrations
├── alembic.ini # Alembic configuration
├── main.py # Application entry point
├── pyproject.toml # Project configuration
├── README.md # Project documentation
├── requirements.txt # Dependencies
└── run.py # Script to run the application
```
## API Endpoints
The API provides the following main endpoints:
### Authentication
- `POST /api/v1/login/access-token` - Login and get access token
- `POST /api/v1/login/test-token` - Test access token validity
### Users
- `GET /api/v1/users/` - List users (admin only)
- `POST /api/v1/users/` - Create user (admin only)
- `GET /api/v1/users/me` - Get current user
- `PUT /api/v1/users/me` - Update current user
### Products
- `GET /api/v1/products/` - List products
- `POST /api/v1/products/` - Create product
- `GET /api/v1/products/{id}` - Get product
- `PUT /api/v1/products/{id}` - Update product
- `DELETE /api/v1/products/{id}` - Delete product
### Inventory
- `GET /api/v1/inventory/` - List inventory
- `POST /api/v1/inventory/` - Create inventory
- `POST /api/v1/inventory/adjust` - Adjust inventory
### Purchase Orders
- `GET /api/v1/purchase-orders/` - List purchase orders
- `POST /api/v1/purchase-orders/` - Create purchase order
- `POST /api/v1/purchase-orders/{id}/receive` - Receive purchase order
### Sales
- `GET /api/v1/sales/` - List sales
- `POST /api/v1/sales/` - Create sale
- `POST /api/v1/sales/{id}/cancel` - Cancel sale
### Reports
- `GET /api/v1/reports/inventory-value` - Inventory value report
- `GET /api/v1/reports/low-stock` - Low stock report
- `GET /api/v1/reports/sales-summary` - Sales summary report
- `GET /api/v1/reports/purchases-summary` - Purchases summary report
- `GET /api/v1/reports/inventory-movements` - Inventory movements report

74
alembic.ini Normal file
View File

@ -0,0 +1,74 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat migrations/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# SQLite database URL - Use absolute path
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
# 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

13
app/api/api_v1/api.py Normal file
View File

@ -0,0 +1,13 @@
from fastapi import APIRouter
from app.api.api_v1.endpoints import users, login, products, categories, inventory, purchase_orders, sales, reports
api_router = APIRouter()
api_router.include_router(login.router, tags=["login"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(categories.router, prefix="/categories", tags=["categories"])
api_router.include_router(products.router, prefix="/products", tags=["products"])
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
api_router.include_router(purchase_orders.router, prefix="/purchase-orders", tags=["purchase-orders"])
api_router.include_router(sales.router, prefix="/sales", tags=["sales"])
api_router.include_router(reports.router, prefix="/reports", tags=["reports"])

View File

@ -0,0 +1,109 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_active_user, get_current_admin_user
from app.crud.crud_category import category
from app.models.user import User as UserModel
from app.schemas.category import Category, CategoryCreate, CategoryUpdate
router = APIRouter()
@router.get("/", response_model=List[Category])
def read_categories(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Retrieve categories.
"""
categories = category.get_multi(db, skip=skip, limit=limit)
return categories
@router.post("/", response_model=Category)
def create_category(
*,
db: Session = Depends(get_db),
category_in: CategoryCreate,
current_user: UserModel = Depends(get_current_admin_user),
) -> Any:
"""
Create new category. Admin only.
"""
db_category = category.get_by_name(db, name=category_in.name)
if db_category:
raise HTTPException(
status_code=400,
detail="A category with this name already exists",
)
new_category = category.create(db, obj_in=category_in)
return new_category
@router.get("/{id}", response_model=Category)
def read_category(
*,
db: Session = Depends(get_db),
id: int,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Get category by ID.
"""
db_category = category.get(db, id=id)
if not db_category:
raise HTTPException(status_code=404, detail="Category not found")
return db_category
@router.put("/{id}", response_model=Category)
def update_category(
*,
db: Session = Depends(get_db),
id: int,
category_in: CategoryUpdate,
current_user: UserModel = Depends(get_current_admin_user),
) -> Any:
"""
Update a category. Admin only.
"""
db_category = category.get(db, id=id)
if not db_category:
raise HTTPException(status_code=404, detail="Category not found")
# Check if updated name conflicts with existing category
if category_in.name and category_in.name != db_category.name:
existing = category.get_by_name(db, name=category_in.name)
if existing:
raise HTTPException(
status_code=400,
detail="A category with this name already exists",
)
updated_category = category.update(db, db_obj=db_category, obj_in=category_in)
return updated_category
@router.delete("/{id}", response_model=Category)
def delete_category(
*,
db: Session = Depends(get_db),
id: int,
current_user: UserModel = Depends(get_current_admin_user),
) -> Any:
"""
Delete a category. Admin only.
"""
db_category = category.get(db, id=id)
if not db_category:
raise HTTPException(status_code=404, detail="Category not found")
# TODO: Check if category has products, and prevent deletion if it does
deleted_category = category.remove(db, id=id)
return deleted_category

View File

@ -0,0 +1,162 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_active_user, get_current_admin_user
from app.crud.crud_inventory import inventory
from app.crud.crud_product import product
from app.models.user import User as UserModel
from app.schemas.inventory import Inventory, InventoryCreate, InventoryUpdate, InventoryAdjustment
router = APIRouter()
@router.get("/", response_model=List[Inventory])
def read_inventory(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
product_id: Optional[int] = None,
location: Optional[str] = None,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Retrieve inventory items.
Filter by product_id or location if provided.
"""
items = inventory.get_multi(db, skip=skip, limit=limit)
# Apply filters
if product_id is not None:
items = [item for item in items if item.product_id == product_id]
if location is not None:
items = [item for item in items if item.location == location]
return items
@router.post("/", response_model=Inventory)
def create_inventory(
*,
db: Session = Depends(get_db),
inventory_in: InventoryCreate,
current_user: UserModel = Depends(get_current_admin_user),
) -> Any:
"""
Create new inventory record. Admin only.
"""
# Verify product exists
prod = product.get(db, id=inventory_in.product_id)
if not prod:
raise HTTPException(status_code=404, detail="Product not found")
# Check if inventory already exists for this product and location
existing = inventory.get_by_product_and_location(
db, product_id=inventory_in.product_id, location=inventory_in.location
)
if existing:
raise HTTPException(
status_code=400,
detail="Inventory record for this product and location already exists. Use adjustment endpoint to modify quantity.",
)
new_inventory = inventory.create(db, obj_in=inventory_in)
return new_inventory
@router.post("/adjust", response_model=Inventory)
def adjust_inventory_quantity(
*,
db: Session = Depends(get_db),
adjustment: InventoryAdjustment,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Adjust inventory quantity for a product.
Use positive quantity to add, negative to remove.
"""
# Verify product exists
prod = product.get(db, id=adjustment.product_id)
if not prod:
raise HTTPException(status_code=404, detail="Product not found")
# Adjust inventory and record transaction
result = inventory.adjust_inventory(db, adjustment=adjustment)
return result["inventory"]
@router.get("/{id}", response_model=Inventory)
def read_inventory_item(
*,
db: Session = Depends(get_db),
id: int,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Get inventory item by ID.
"""
item = inventory.get(db, id=id)
if not item:
raise HTTPException(status_code=404, detail="Inventory item not found")
return item
@router.put("/{id}", response_model=Inventory)
def update_inventory(
*,
db: Session = Depends(get_db),
id: int,
inventory_in: InventoryUpdate,
current_user: UserModel = Depends(get_current_admin_user),
) -> Any:
"""
Update an inventory item. Admin only.
Note: For regular quantity adjustments, use the /adjust endpoint instead.
"""
item = inventory.get(db, id=id)
if not item:
raise HTTPException(status_code=404, detail="Inventory item not found")
# If changing product_id, verify product exists
if inventory_in.product_id is not None and inventory_in.product_id != item.product_id:
prod = product.get(db, id=inventory_in.product_id)
if not prod:
raise HTTPException(status_code=404, detail="Product not found")
# Check if inventory already exists for new product and location
existing = inventory.get_by_product_and_location(
db,
product_id=inventory_in.product_id,
location=inventory_in.location or item.location
)
if existing and existing.id != id:
raise HTTPException(
status_code=400,
detail="Inventory record for this product and location already exists",
)
updated_inventory = inventory.update(db, db_obj=item, obj_in=inventory_in)
return updated_inventory
@router.delete("/{id}", response_model=Inventory)
def delete_inventory(
*,
db: Session = Depends(get_db),
id: int,
current_user: UserModel = Depends(get_current_admin_user),
) -> Any:
"""
Delete an inventory item. Admin only.
"""
item = inventory.get(db, id=id)
if not item:
raise HTTPException(status_code=404, detail="Inventory item not found")
deleted_inventory = inventory.remove(db, id=id)
return deleted_inventory

View File

@ -0,0 +1,48 @@
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.api.deps import get_db, get_current_user
from app.core.config import settings
from app.core.security.jwt import create_access_token
from app.core.security.password import verify_password
from app.crud.crud_user import user
from app.schemas.token import Token
from app.schemas.user import User
router = APIRouter()
@router.post("/login/access-token", response_model=Token)
def login_access_token(
db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests
"""
db_user = user.get_by_email(db, email=form_data.username)
if not db_user:
raise HTTPException(status_code=400, detail="Incorrect email or password")
if not verify_password(form_data.password, db_user.hashed_password):
raise HTTPException(status_code=400, detail="Incorrect email or password")
if not db_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return {
"access_token": create_access_token(
db_user.id, expires_delta=access_token_expires
),
"token_type": "bearer",
}
@router.post("/login/test-token", response_model=User)
def test_token(current_user: User = Depends(get_current_user)) -> Any:
"""
Test access token
"""
return current_user

View File

@ -0,0 +1,170 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_active_user, get_current_admin_user
from app.crud.crud_product import product
from app.models.user import User as UserModel
from app.schemas.product import Product, ProductCreate, ProductUpdate, ProductWithInventory
router = APIRouter()
@router.get("/", response_model=List[ProductWithInventory])
def read_products(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
category_id: Optional[int] = None,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Retrieve products with inventory information.
"""
products = product.get_multi_with_inventory(db, skip=skip, limit=limit)
# Filter by category if provided
if category_id is not None:
products = [p for p in products if p["category_id"] == category_id]
return products
@router.post("/", response_model=Product)
def create_product(
*,
db: Session = Depends(get_db),
product_in: ProductCreate,
current_user: UserModel = Depends(get_current_admin_user),
) -> Any:
"""
Create new product. Admin only.
"""
# Check for duplicate SKU
if product_in.sku:
db_product = product.get_by_sku(db, sku=product_in.sku)
if db_product:
raise HTTPException(
status_code=400,
detail="A product with this SKU already exists",
)
# Check for duplicate barcode
if product_in.barcode:
db_product = product.get_by_barcode(db, barcode=product_in.barcode)
if db_product:
raise HTTPException(
status_code=400,
detail="A product with this barcode already exists",
)
new_product = product.create(db, obj_in=product_in)
return new_product
@router.get("/{id}", response_model=ProductWithInventory)
def read_product(
*,
db: Session = Depends(get_db),
id: int,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Get product by ID with inventory information.
"""
db_product = product.get_with_inventory(db, id=id)
if not db_product:
raise HTTPException(status_code=404, detail="Product not found")
return db_product
@router.put("/{id}", response_model=Product)
def update_product(
*,
db: Session = Depends(get_db),
id: int,
product_in: ProductUpdate,
current_user: UserModel = Depends(get_current_admin_user),
) -> Any:
"""
Update a product. Admin only.
"""
db_product = product.get(db, id=id)
if not db_product:
raise HTTPException(status_code=404, detail="Product not found")
# Check for duplicate SKU
if product_in.sku and product_in.sku != db_product.sku:
existing = product.get_by_sku(db, sku=product_in.sku)
if existing:
raise HTTPException(
status_code=400,
detail="A product with this SKU already exists",
)
# Check for duplicate barcode
if product_in.barcode and product_in.barcode != db_product.barcode:
existing = product.get_by_barcode(db, barcode=product_in.barcode)
if existing:
raise HTTPException(
status_code=400,
detail="A product with this barcode already exists",
)
updated_product = product.update(db, db_obj=db_product, obj_in=product_in)
return updated_product
@router.delete("/{id}", response_model=Product)
def delete_product(
*,
db: Session = Depends(get_db),
id: int,
current_user: UserModel = Depends(get_current_admin_user),
) -> Any:
"""
Delete a product. Admin only.
"""
db_product = product.get(db, id=id)
if not db_product:
raise HTTPException(status_code=404, detail="Product not found")
# TODO: Consider checking if product has inventory or is referenced by other entities
deleted_product = product.remove(db, id=id)
return deleted_product
@router.get("/by-sku/{sku}", response_model=ProductWithInventory)
def read_product_by_sku(
*,
db: Session = Depends(get_db),
sku: str,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Get product by SKU with inventory information.
"""
db_product = product.get_by_sku(db, sku=sku)
if not db_product:
raise HTTPException(status_code=404, detail="Product not found")
return product.get_with_inventory(db, id=db_product.id)
@router.get("/by-barcode/{barcode}", response_model=ProductWithInventory)
def read_product_by_barcode(
*,
db: Session = Depends(get_db),
barcode: str,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Get product by barcode with inventory information.
"""
db_product = product.get_by_barcode(db, barcode=barcode)
if not db_product:
raise HTTPException(status_code=404, detail="Product not found")
return product.get_with_inventory(db, id=db_product.id)

View File

@ -0,0 +1,185 @@
from typing import Any, List, Optional
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_active_user, get_current_admin_user
from app.crud.crud_purchase_order import purchase_order
from app.crud.crud_product import product
from app.models.user import User as UserModel
from app.schemas.purchase_order import PurchaseOrder, PurchaseOrderCreate, PurchaseOrderUpdate
router = APIRouter()
@router.get("/", response_model=List[PurchaseOrder])
def read_purchase_orders(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
status: Optional[str] = None,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Retrieve purchase orders with their items.
Filter by status if provided.
"""
orders = purchase_order.get_multi_with_items(db, skip=skip, limit=limit)
# Apply status filter
if status:
orders = [order for order in orders if order.status == status]
# Calculate total amount for each order
for order in orders:
order.total_amount = purchase_order.get_total_amount(db, id=order.id)
return orders
@router.post("/", response_model=PurchaseOrder)
def create_purchase_order(
*,
db: Session = Depends(get_db),
order_in: PurchaseOrderCreate,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Create new purchase order with items.
"""
# Verify all products exist
for item in order_in.items:
prod = product.get(db, id=item.product_id)
if not prod:
raise HTTPException(
status_code=404,
detail=f"Product with id {item.product_id} not found"
)
# Create purchase order with items
new_order = purchase_order.create_with_items(
db, obj_in=order_in, user_id=current_user.id
)
# Calculate total amount
new_order.total_amount = purchase_order.get_total_amount(db, id=new_order.id)
return new_order
@router.get("/{id}", response_model=PurchaseOrder)
def read_purchase_order(
*,
db: Session = Depends(get_db),
id: int,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Get purchase order by ID with its items.
"""
order = purchase_order.get_with_items(db, id=id)
if not order:
raise HTTPException(status_code=404, detail="Purchase order not found")
# Calculate total amount
order.total_amount = purchase_order.get_total_amount(db, id=order.id)
return order
@router.put("/{id}", response_model=PurchaseOrder)
def update_purchase_order(
*,
db: Session = Depends(get_db),
id: int,
order_in: PurchaseOrderUpdate,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Update a purchase order (but not its items).
Can only update pending orders.
"""
order = purchase_order.get(db, id=id)
if not order:
raise HTTPException(status_code=404, detail="Purchase order not found")
# Only allow updates to pending orders
if order.status != "pending":
raise HTTPException(
status_code=400,
detail=f"Cannot update purchase order with status {order.status}. Only pending orders can be updated."
)
updated_order = purchase_order.update(db, db_obj=order, obj_in=order_in)
# Get full order with items for response
result = purchase_order.get_with_items(db, id=updated_order.id)
# Calculate total amount
result.total_amount = purchase_order.get_total_amount(db, id=result.id)
return result
@router.post("/{id}/receive", response_model=PurchaseOrder)
def receive_purchase_order(
*,
db: Session = Depends(get_db),
id: int,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Mark a purchase order as received and update inventory.
"""
order = purchase_order.get(db, id=id)
if not order:
raise HTTPException(status_code=404, detail="Purchase order not found")
# Only allow receiving pending orders
if order.status != "pending":
raise HTTPException(
status_code=400,
detail=f"Cannot receive purchase order with status {order.status}. Only pending orders can be received."
)
# Update status and inventory
received_order = purchase_order.receive_order(db, id=id)
# Calculate total amount
received_order.total_amount = purchase_order.get_total_amount(db, id=received_order.id)
return received_order
@router.post("/{id}/cancel", response_model=PurchaseOrder)
def cancel_purchase_order(
*,
db: Session = Depends(get_db),
id: int,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Cancel a purchase order.
"""
order = purchase_order.get(db, id=id)
if not order:
raise HTTPException(status_code=404, detail="Purchase order not found")
# Only allow cancelling pending orders
if order.status != "pending":
raise HTTPException(
status_code=400,
detail=f"Cannot cancel purchase order with status {order.status}. Only pending orders can be cancelled."
)
# Update status
cancelled_order = purchase_order.cancel_order(db, id=id)
# Get full order with items for response
result = purchase_order.get_with_items(db, id=cancelled_order.id)
# Calculate total amount
result.total_amount = purchase_order.get_total_amount(db, id=result.id)
return result

View File

@ -0,0 +1,341 @@
from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, and_, desc
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_active_user, get_current_admin_user
from app.models.user import User as UserModel
from app.models.product import Product
from app.models.inventory import Inventory, InventoryTransaction
from app.models.purchase_order import PurchaseOrder, PurchaseOrderItem
from app.models.sale import Sale, SaleItem
router = APIRouter()
@router.get("/inventory-value")
def inventory_value_report(
db: Session = Depends(get_db),
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Get current inventory value report.
Calculates total inventory value (quantity * cost price).
"""
# Join Product and Inventory to calculate value
inventory_value = db.query(
func.sum(Product.cost_price * Inventory.quantity).label("total_value"),
func.count(Inventory.id).label("items_count"),
func.sum(Inventory.quantity).label("total_quantity")
).join(
Product, Product.id == Inventory.product_id
).filter(
Inventory.quantity > 0
).first()
# Get top products by value
top_products = db.query(
Product.id,
Product.name,
Product.sku,
Product.cost_price,
Inventory.quantity,
(Product.cost_price * Inventory.quantity).label("value")
).join(
Inventory, Product.id == Inventory.product_id
).filter(
Inventory.quantity > 0
).order_by(
desc("value")
).limit(10).all()
top_products_result = []
for p in top_products:
top_products_result.append({
"id": p.id,
"name": p.name,
"sku": p.sku,
"cost_price": float(p.cost_price) if p.cost_price else 0,
"quantity": p.quantity,
"value": float(p.value) if p.value else 0
})
return {
"total_value": float(inventory_value.total_value) if inventory_value.total_value else 0,
"items_count": inventory_value.items_count or 0,
"total_quantity": inventory_value.total_quantity or 0,
"top_products_by_value": top_products_result,
"report_date": datetime.now()
}
@router.get("/low-stock")
def low_stock_report(
db: Session = Depends(get_db),
threshold: int = 5,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Get low stock report.
Shows products with inventory below specified threshold.
"""
# Get products with low stock
low_stock_products = db.query(
Product.id,
Product.name,
Product.sku,
Product.barcode,
func.sum(Inventory.quantity).label("total_quantity")
).outerjoin(
Inventory, Product.id == Inventory.product_id
).group_by(
Product.id
).having(
func.coalesce(func.sum(Inventory.quantity), 0) < threshold
).all()
result = []
for p in low_stock_products:
result.append({
"id": p.id,
"name": p.name,
"sku": p.sku,
"barcode": p.barcode,
"quantity": p.total_quantity or 0
})
return {
"threshold": threshold,
"count": len(result),
"products": result,
"report_date": datetime.now()
}
@router.get("/sales-summary")
def sales_summary_report(
db: Session = Depends(get_db),
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Get sales summary report for a specified date range.
If no dates provided, defaults to the last 30 days.
"""
# Set default date range if not provided
if not end_date:
end_date = datetime.now()
if not start_date:
start_date = end_date - timedelta(days=30)
# Get summary of all sales in date range
sales_summary = db.query(
func.count(Sale.id).label("total_sales"),
func.sum(SaleItem.quantity * SaleItem.unit_price).label("total_revenue"),
func.sum(SaleItem.quantity).label("total_items_sold")
).join(
SaleItem, Sale.id == SaleItem.sale_id
).filter(
Sale.status == "completed",
Sale.created_at >= start_date,
Sale.created_at <= end_date
).first()
# Get top selling products
top_products = db.query(
Product.id,
Product.name,
Product.sku,
func.sum(SaleItem.quantity).label("quantity_sold"),
func.sum(SaleItem.quantity * SaleItem.unit_price).label("revenue")
).join(
SaleItem, Product.id == SaleItem.product_id
).join(
Sale, SaleItem.sale_id == Sale.id
).filter(
Sale.status == "completed",
Sale.created_at >= start_date,
Sale.created_at <= end_date
).group_by(
Product.id
).order_by(
desc("quantity_sold")
).limit(10).all()
top_products_result = []
for p in top_products:
top_products_result.append({
"id": p.id,
"name": p.name,
"sku": p.sku,
"quantity_sold": p.quantity_sold,
"revenue": float(p.revenue) if p.revenue else 0
})
return {
"start_date": start_date,
"end_date": end_date,
"total_sales": sales_summary.total_sales or 0,
"total_revenue": float(sales_summary.total_revenue) if sales_summary.total_revenue else 0,
"total_items_sold": sales_summary.total_items_sold or 0,
"top_selling_products": top_products_result,
"report_date": datetime.now()
}
@router.get("/purchases-summary")
def purchases_summary_report(
db: Session = Depends(get_db),
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Get purchases summary report for a specified date range.
If no dates provided, defaults to the last 30 days.
"""
# Set default date range if not provided
if not end_date:
end_date = datetime.now()
if not start_date:
start_date = end_date - timedelta(days=30)
# Get summary of all received purchase orders in date range
purchases_summary = db.query(
func.count(PurchaseOrder.id).label("total_purchase_orders"),
func.sum(PurchaseOrderItem.quantity * PurchaseOrderItem.unit_price).label("total_cost"),
func.sum(PurchaseOrderItem.quantity).label("total_items_purchased")
).join(
PurchaseOrderItem, PurchaseOrder.id == PurchaseOrderItem.purchase_order_id
).filter(
PurchaseOrder.status == "received",
PurchaseOrder.created_at >= start_date,
PurchaseOrder.created_at <= end_date
).first()
# Get top suppliers
top_suppliers = db.query(
PurchaseOrder.supplier_name,
func.count(PurchaseOrder.id).label("order_count"),
func.sum(PurchaseOrderItem.quantity * PurchaseOrderItem.unit_price).label("total_spend")
).join(
PurchaseOrderItem, PurchaseOrder.id == PurchaseOrderItem.purchase_order_id
).filter(
PurchaseOrder.status == "received",
PurchaseOrder.created_at >= start_date,
PurchaseOrder.created_at <= end_date
).group_by(
PurchaseOrder.supplier_name
).order_by(
desc("total_spend")
).limit(5).all()
top_suppliers_result = []
for s in top_suppliers:
top_suppliers_result.append({
"supplier_name": s.supplier_name,
"order_count": s.order_count,
"total_spend": float(s.total_spend) if s.total_spend else 0
})
return {
"start_date": start_date,
"end_date": end_date,
"total_purchase_orders": purchases_summary.total_purchase_orders or 0,
"total_cost": float(purchases_summary.total_cost) if purchases_summary.total_cost else 0,
"total_items_purchased": purchases_summary.total_items_purchased or 0,
"top_suppliers": top_suppliers_result,
"report_date": datetime.now()
}
@router.get("/inventory-movements")
def inventory_movements_report(
db: Session = Depends(get_db),
product_id: Optional[int] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Get inventory movements report for a specified date range and product.
If no dates provided, defaults to the last 30 days.
"""
# Set default date range if not provided
if not end_date:
end_date = datetime.now()
if not start_date:
start_date = end_date - timedelta(days=30)
# Build query for inventory transactions
query = db.query(
InventoryTransaction.id,
InventoryTransaction.product_id,
Product.name.label("product_name"),
Product.sku,
InventoryTransaction.quantity,
InventoryTransaction.transaction_type,
InventoryTransaction.reference_id,
InventoryTransaction.reason,
InventoryTransaction.timestamp,
InventoryTransaction.location
).join(
Product, InventoryTransaction.product_id == Product.id
).filter(
InventoryTransaction.timestamp >= start_date,
InventoryTransaction.timestamp <= end_date
)
# Filter by product if specified
if product_id:
query = query.filter(InventoryTransaction.product_id == product_id)
# Execute query
transactions = query.order_by(InventoryTransaction.timestamp.desc()).all()
result = []
for t in transactions:
result.append({
"id": t.id,
"product_id": t.product_id,
"product_name": t.product_name,
"sku": t.sku,
"quantity": t.quantity,
"transaction_type": t.transaction_type,
"reference_id": t.reference_id,
"reason": t.reason,
"timestamp": t.timestamp,
"location": t.location
})
# Get summary by transaction type
summary = db.query(
InventoryTransaction.transaction_type,
func.sum(InventoryTransaction.quantity).label("total_quantity")
).filter(
InventoryTransaction.timestamp >= start_date,
InventoryTransaction.timestamp <= end_date
)
if product_id:
summary = summary.filter(InventoryTransaction.product_id == product_id)
summary = summary.group_by(InventoryTransaction.transaction_type).all()
summary_result = {}
for s in summary:
summary_result[s.transaction_type] = s.total_quantity
return {
"start_date": start_date,
"end_date": end_date,
"product_id": product_id,
"transaction_count": len(result),
"summary_by_type": summary_result,
"transactions": result,
"report_date": datetime.now()
}

View File

@ -0,0 +1,168 @@
from typing import Any, List, Optional
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_active_user, get_current_admin_user
from app.crud.crud_sale import sale
from app.crud.crud_product import product
from app.crud.crud_inventory import inventory
from app.models.user import User as UserModel
from app.schemas.sale import Sale, SaleCreate, SaleUpdate
router = APIRouter()
@router.get("/", response_model=List[Sale])
def read_sales(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
status: Optional[str] = None,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Retrieve sales with their items.
Filter by status if provided.
"""
sales = sale.get_multi_with_items(db, skip=skip, limit=limit)
# Apply status filter
if status:
sales = [s for s in sales if s.status == status]
# Calculate total amount for each sale
for s in sales:
s.total_amount = sale.get_total_amount(db, id=s.id)
return sales
@router.post("/", response_model=Sale)
def create_sale(
*,
db: Session = Depends(get_db),
sale_in: SaleCreate,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Create new sale with items and update inventory.
"""
# Verify all products exist and have enough inventory
for item in sale_in.items:
# Check if product exists
prod = product.get(db, id=item.product_id)
if not prod:
raise HTTPException(
status_code=404,
detail=f"Product with id {item.product_id} not found"
)
# Check if enough inventory is available
available = inventory.get_total_product_quantity(db, product_id=item.product_id)
if available < item.quantity:
raise HTTPException(
status_code=400,
detail=f"Not enough inventory for product {prod.name}. Available: {available}, Requested: {item.quantity}"
)
# Create sale with items
new_sale = sale.create_with_items(
db, obj_in=sale_in, user_id=current_user.id
)
if not new_sale:
raise HTTPException(
status_code=400,
detail="Failed to create sale, likely due to insufficient inventory"
)
# Calculate total amount
new_sale.total_amount = sale.get_total_amount(db, id=new_sale.id)
return new_sale
@router.get("/{id}", response_model=Sale)
def read_sale(
*,
db: Session = Depends(get_db),
id: int,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Get sale by ID with its items.
"""
sale_item = sale.get_with_items(db, id=id)
if not sale_item:
raise HTTPException(status_code=404, detail="Sale not found")
# Calculate total amount
sale_item.total_amount = sale.get_total_amount(db, id=sale_item.id)
return sale_item
@router.put("/{id}", response_model=Sale)
def update_sale(
*,
db: Session = Depends(get_db),
id: int,
sale_in: SaleUpdate,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Update a sale (but not its items).
Can only update completed sales that haven't been cancelled or returned.
"""
sale_item = sale.get(db, id=id)
if not sale_item:
raise HTTPException(status_code=404, detail="Sale not found")
# Only allow updates to completed sales
if sale_item.status != "completed":
raise HTTPException(
status_code=400,
detail=f"Cannot update sale with status {sale_item.status}. Only completed sales can be updated."
)
updated_sale = sale.update(db, db_obj=sale_item, obj_in=sale_in)
# Get full sale with items for response
result = sale.get_with_items(db, id=updated_sale.id)
# Calculate total amount
result.total_amount = sale.get_total_amount(db, id=result.id)
return result
@router.post("/{id}/cancel", response_model=Sale)
def cancel_sale(
*,
db: Session = Depends(get_db),
id: int,
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Cancel a sale and return items to inventory.
"""
sale_item = sale.get(db, id=id)
if not sale_item:
raise HTTPException(status_code=404, detail="Sale not found")
# Only allow cancelling completed sales
if sale_item.status != "completed":
raise HTTPException(
status_code=400,
detail=f"Cannot cancel sale with status {sale_item.status}. Only completed sales can be cancelled."
)
# Update status and return to inventory
cancelled_sale = sale.cancel_sale(db, id=id)
# Calculate total amount
cancelled_sale.total_amount = sale.get_total_amount(db, id=cancelled_sale.id)
return cancelled_sale

View File

@ -0,0 +1,116 @@
from typing import Any, List
from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.encoders import jsonable_encoder
from pydantic import EmailStr
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_active_user, get_current_admin_user
from app.crud.crud_user import user
from app.models.user import User as UserModel
from app.schemas.user import User, UserCreate, UserUpdate
router = APIRouter()
@router.get("/", response_model=List[User])
def read_users(
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
current_user: UserModel = Depends(get_current_admin_user),
) -> Any:
"""
Retrieve users. Admin only.
"""
users = user.get_multi(db, skip=skip, limit=limit)
return users
@router.post("/", response_model=User)
def create_user(
*,
db: Session = Depends(get_db),
user_in: UserCreate,
current_user: UserModel = Depends(get_current_admin_user),
) -> Any:
"""
Create new user. Admin only.
"""
db_user = user.get_by_email(db, email=user_in.email)
if db_user:
raise HTTPException(
status_code=400,
detail="A user with this email already exists",
)
new_user = user.create(db, obj_in=user_in)
return new_user
@router.put("/me", response_model=User)
def update_user_me(
*,
db: Session = Depends(get_db),
password: str = Body(None),
full_name: str = Body(None),
email: EmailStr = Body(None),
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Update own user.
"""
current_user_data = jsonable_encoder(current_user)
user_in = 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
updated_user = user.update(db, db_obj=current_user, obj_in=user_in)
return updated_user
@router.get("/me", response_model=User)
def read_user_me(
current_user: UserModel = Depends(get_current_active_user),
) -> Any:
"""
Get current user.
"""
return current_user
@router.get("/{user_id}", response_model=User)
def read_user_by_id(
user_id: int,
current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db),
) -> Any:
"""
Get a specific user by id.
"""
db_user = user.get(db, id=user_id)
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
if user_id != current_user.id and not current_user.is_admin:
raise HTTPException(status_code=400, detail="Not enough permissions")
return db_user
@router.put("/{user_id}", response_model=User)
def update_user(
*,
db: Session = Depends(get_db),
user_id: int,
user_in: UserUpdate,
current_user: UserModel = Depends(get_current_admin_user),
) -> Any:
"""
Update a user. Admin only.
"""
db_user = user.get(db, id=user_id)
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
updated_user = user.update(db, db_obj=db_user, obj_in=user_in)
return updated_user

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

@ -0,0 +1,60 @@
from typing import Generator
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db.session import get_db
from app.core.security.jwt import decode_token
from app.crud.crud_user import user
from app.models.user import User as UserModel
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
)
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)
) -> UserModel:
"""
Get current user based on JWT token
"""
token_data = decode_token(token)
if not token_data:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
db_user = user.get(db, id=token_data.sub)
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
return db_user
def get_current_active_user(
current_user: UserModel = Depends(get_current_user),
) -> UserModel:
"""
Get current active user
"""
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
def get_current_admin_user(
current_user: UserModel = Depends(get_current_user),
) -> UserModel:
"""
Get current admin user
"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The user doesn't have enough privileges"
)
return current_user

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

@ -0,0 +1,42 @@
import secrets
from typing import Any, Dict, List, Optional, Union
from pathlib import Path
from pydantic import AnyHttpUrl, validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
API_V1_STR: str = "/api/v1"
SECRET_KEY: str = secrets.token_urlsafe(32)
# 60 minutes * 24 hours * 8 days = 8 days
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
# BACKEND_CORS_ORIGINS is a JSON-formatted list of origins
# e.g: '["http://localhost", "http://localhost:4200", "http://localhost:3000", \
# "http://localhost:8080", "http://local.dockertoolbox.tiangolo.com"]'
BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[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)
PROJECT_NAME: str = "Small Business Inventory Management System"
# Database configuration
DB_DIR: Path = Path("/app") / "storage" / "db"
# Make sure DB directory exists
DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite"
class Config:
case_sensitive = True
env_file = ".env"
settings = Settings()

39
app/core/health.py Normal file
View File

@ -0,0 +1,39 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
import time
health_router = APIRouter()
@health_router.get("/health", tags=["health"])
async def health_check(db: Session = Depends(get_db)):
"""
Check service health
Checks:
- API is responsive
- Database connection is established
"""
# Basic health check
health_data = {
"status": "healthy",
"timestamp": time.time(),
"version": "0.1.0",
"checks": {
"api": "ok",
"database": "ok"
}
}
# Verify database connection
try:
# Run simple query to verify connection
db.execute("SELECT 1")
except Exception as e:
health_data["status"] = "unhealthy"
health_data["checks"]["database"] = "failed"
return health_data
return health_data

View File

@ -0,0 +1,64 @@
from typing import Generator, Optional
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.core.config import settings
from app.core.security.jwt import ALGORITHM, decode_token
from app.db.session import get_db
from app.models.user import User
from app.schemas.token import TokenPayload
from app.crud.crud_user import user
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
)
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)
) -> User:
"""
Get current user based on JWT token
"""
token_data = decode_token(token)
if not token_data:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
db_user = user.get(db, id=token_data.sub)
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
return db_user
def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Get current active user
"""
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
def get_current_admin_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Get current admin user
"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The user doesn't have enough privileges"
)
return current_user

46
app/core/security/jwt.py Normal file
View File

@ -0,0 +1,46 @@
from datetime import datetime, timedelta
from typing import Any, Optional, Union
from jose import jwt
from pydantic import ValidationError
from app.core.config import settings
from app.schemas.token import TokenPayload
ALGORITHM = "HS256"
def create_access_token(
subject: Union[str, Any], expires_delta: Optional[timedelta] = None
) -> str:
"""
Create a new JWT token
"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> Optional[TokenPayload]:
"""
Decode and validate a JWT token
"""
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[ALGORITHM]
)
token_data = TokenPayload(**payload)
if datetime.fromtimestamp(token_data.exp) < datetime.utcnow():
return None
return token_data
except (jwt.JWTError, ValidationError):
return None

View File

@ -0,0 +1,18 @@
from passlib.context import CryptContext
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
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:
"""
Generate password hash from plain text password
"""
return pwd_context.hash(password)

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

@ -0,0 +1,66 @@
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]):
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]:
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]:
return db.query(self.model).offset(skip).limit(limit).all()
def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
obj_in_data = jsonable_encoder(obj_in)
db_obj = self.model(**obj_in_data)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self,
db: Session,
*,
db_obj: ModelType,
obj_in: Union[UpdateSchemaType, Dict[str, Any]]
) -> ModelType:
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(self, db: Session, *, id: int) -> ModelType:
obj = db.query(self.model).get(id)
db.delete(obj)
db.commit()
return obj

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

@ -0,0 +1,15 @@
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]):
def get_by_name(self, db: Session, *, name: str) -> Optional[Category]:
return db.query(Category).filter(Category.name == name).first()
category = CRUDCategory(Category)

View File

@ -0,0 +1,84 @@
from typing import List, Optional, Dict, Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.inventory import Inventory, InventoryTransaction
from app.models.product import Product
from app.schemas.inventory import InventoryCreate, InventoryUpdate, InventoryAdjustment
class CRUDInventory(CRUDBase[Inventory, InventoryCreate, InventoryUpdate]):
def get_by_product_and_location(
self, db: Session, *, product_id: int, location: Optional[str] = None
) -> Optional[Inventory]:
"""Get inventory by product and location"""
query = db.query(Inventory).filter(Inventory.product_id == product_id)
if location:
query = query.filter(Inventory.location == location)
return query.first()
def get_product_inventory(
self, db: Session, *, product_id: int
) -> List[Inventory]:
"""Get all inventory records for a product"""
return db.query(Inventory).filter(Inventory.product_id == product_id).all()
def get_total_product_quantity(
self, db: Session, *, product_id: int
) -> int:
"""Get total inventory quantity for a product across all locations"""
result = db.query(
func.sum(Inventory.quantity).label("total")
).filter(
Inventory.product_id == product_id
).scalar()
return result or 0
def adjust_inventory(
self, db: Session, *, adjustment: InventoryAdjustment
) -> Dict[str, Any]:
"""Adjust inventory quantity and record the transaction"""
# Find or create inventory record
inventory = self.get_by_product_and_location(
db, product_id=adjustment.product_id, location=adjustment.location
)
if not inventory:
# Create new inventory record if it doesn't exist
inventory = Inventory(
product_id=adjustment.product_id,
quantity=0, # Start with 0, will add adjustment below
location=adjustment.location
)
db.add(inventory)
db.flush()
# Update quantity
inventory.quantity += adjustment.quantity
# Ensure quantity is never negative
if inventory.quantity < 0:
inventory.quantity = 0
# Record transaction
transaction = InventoryTransaction(
product_id=adjustment.product_id,
quantity=adjustment.quantity,
transaction_type="adjustment",
reason=adjustment.reason,
location=adjustment.location
)
db.add(transaction)
db.commit()
db.refresh(inventory)
db.refresh(transaction)
return {
"inventory": inventory,
"transaction": transaction
}
inventory = CRUDInventory(Inventory)

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

@ -0,0 +1,69 @@
from typing import List, Optional, Dict, Any
from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload
from app.crud.base import CRUDBase
from app.models.product import Product
from app.models.inventory import Inventory
from app.schemas.product import ProductCreate, ProductUpdate
class CRUDProduct(CRUDBase[Product, ProductCreate, ProductUpdate]):
def get_with_inventory(self, db: Session, id: int) -> Optional[Dict[str, Any]]:
"""Get product with inventory quantities"""
product = db.query(Product).filter(Product.id == id).first()
if not product:
return None
# Get total inventory for this product
inventory = db.query(
func.sum(Inventory.quantity).label("total_quantity")
).filter(
Inventory.product_id == id
).scalar() or 0
result = {
**product.__dict__,
"total_quantity": inventory,
"available_quantity": inventory # For now they're the same since we don't track reserved items
}
return result
def get_multi_with_inventory(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[Dict[str, Any]]:
"""Get multiple products with their inventory quantities"""
products = db.query(Product).offset(skip).limit(limit).all()
# Get inventory counts for all products in one query
inventory_counts = dict(
db.query(
Inventory.product_id,
func.sum(Inventory.quantity).label("total")
).group_by(
Inventory.product_id
).all()
)
result = []
for product in products:
total_quantity = inventory_counts.get(product.id, 0)
result.append({
**product.__dict__,
"total_quantity": total_quantity,
"available_quantity": total_quantity # For now they're the same
})
return result
def get_by_sku(self, db: Session, *, sku: str) -> Optional[Product]:
return db.query(Product).filter(Product.sku == sku).first()
def get_by_barcode(self, db: Session, *, barcode: str) -> Optional[Product]:
return db.query(Product).filter(Product.barcode == barcode).first()
product = CRUDProduct(Product)

View File

@ -0,0 +1,129 @@
from typing import List, Optional, Dict, Any
from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload
from decimal import Decimal
from app.crud.base import CRUDBase
from app.models.purchase_order import PurchaseOrder, PurchaseOrderItem
from app.models.inventory import Inventory, InventoryTransaction
from app.schemas.purchase_order import PurchaseOrderCreate, PurchaseOrderUpdate
class CRUDPurchaseOrder(CRUDBase[PurchaseOrder, PurchaseOrderCreate, PurchaseOrderUpdate]):
def create_with_items(
self, db: Session, *, obj_in: PurchaseOrderCreate, user_id: int
) -> PurchaseOrder:
"""Create purchase order with items"""
# Create purchase order
db_obj = PurchaseOrder(
supplier_name=obj_in.supplier_name,
notes=obj_in.notes,
status=obj_in.status,
created_by=user_id
)
db.add(db_obj)
db.flush()
# Create items
for item in obj_in.items:
db_item = PurchaseOrderItem(
purchase_order_id=db_obj.id,
product_id=item.product_id,
quantity=item.quantity,
unit_price=item.unit_price
)
db.add(db_item)
db.commit()
db.refresh(db_obj)
return db_obj
def get_with_items(self, db: Session, id: int) -> Optional[PurchaseOrder]:
"""Get purchase order with its items"""
return db.query(PurchaseOrder).options(
joinedload(PurchaseOrder.items).joinedload(PurchaseOrderItem.product)
).filter(PurchaseOrder.id == id).first()
def get_multi_with_items(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[PurchaseOrder]:
"""Get multiple purchase orders with their items"""
return db.query(PurchaseOrder).options(
joinedload(PurchaseOrder.items).joinedload(PurchaseOrderItem.product)
).offset(skip).limit(limit).all()
def receive_order(self, db: Session, *, id: int) -> Optional[PurchaseOrder]:
"""Mark a purchase order as received and update inventory"""
purchase_order = self.get_with_items(db, id)
if not purchase_order:
return None
if purchase_order.status != "pending":
return purchase_order # Already processed or cancelled
# Update purchase order status
purchase_order.status = "received"
# Update inventory for each item
for item in purchase_order.items:
# Find or create inventory
inventory = db.query(Inventory).filter(
Inventory.product_id == item.product_id,
Inventory.location == None # Default location
).first()
if not inventory:
inventory = Inventory(
product_id=item.product_id,
quantity=0,
location=None # Default location
)
db.add(inventory)
db.flush()
# Update quantity
inventory.quantity += item.quantity
# Record transaction
transaction = InventoryTransaction(
product_id=item.product_id,
quantity=item.quantity,
transaction_type="purchase",
reference_id=purchase_order.id,
reason=f"Received from {purchase_order.supplier_name}"
)
db.add(transaction)
db.commit()
db.refresh(purchase_order)
return purchase_order
def cancel_order(self, db: Session, *, id: int) -> Optional[PurchaseOrder]:
"""Cancel a purchase order"""
purchase_order = db.query(PurchaseOrder).filter(PurchaseOrder.id == id).first()
if not purchase_order:
return None
if purchase_order.status != "pending":
return purchase_order # Already processed or cancelled
purchase_order.status = "cancelled"
db.commit()
db.refresh(purchase_order)
return purchase_order
def get_total_amount(self, db: Session, *, id: int) -> Decimal:
"""Calculate total amount for a purchase order"""
result = db.query(
func.sum(PurchaseOrderItem.quantity * PurchaseOrderItem.unit_price).label("total")
).filter(
PurchaseOrderItem.purchase_order_id == id
).scalar()
return result or Decimal("0.00")
purchase_order = CRUDPurchaseOrder(PurchaseOrder)

177
app/crud/crud_sale.py Normal file
View File

@ -0,0 +1,177 @@
from typing import List, Optional, Dict, Any
from sqlalchemy import func
from sqlalchemy.orm import Session, joinedload
from decimal import Decimal
from app.crud.base import CRUDBase
from app.models.sale import Sale, SaleItem
from app.models.inventory import Inventory, InventoryTransaction
from app.schemas.sale import SaleCreate, SaleUpdate
class CRUDSale(CRUDBase[Sale, SaleCreate, SaleUpdate]):
def create_with_items(
self, db: Session, *, obj_in: SaleCreate, user_id: int
) -> Optional[Sale]:
"""Create sale with items and update inventory"""
# First check if we have enough inventory
for item in obj_in.items:
inventory_qty = db.query(func.sum(Inventory.quantity)).filter(
Inventory.product_id == item.product_id
).scalar() or 0
if inventory_qty < item.quantity:
# Not enough inventory
return None
# Create sale
db_obj = Sale(
customer_name=obj_in.customer_name,
notes=obj_in.notes,
status=obj_in.status,
created_by=user_id
)
db.add(db_obj)
db.flush()
# Create items and update inventory
for item in obj_in.items:
db_item = SaleItem(
sale_id=db_obj.id,
product_id=item.product_id,
quantity=item.quantity,
unit_price=item.unit_price
)
db.add(db_item)
# Update inventory - reduce quantities
self._reduce_inventory(
db,
product_id=item.product_id,
quantity=item.quantity,
sale_id=db_obj.id
)
db.commit()
db.refresh(db_obj)
return db_obj
def _reduce_inventory(
self, db: Session, *, product_id: int, quantity: int, sale_id: int
) -> None:
"""Reduce inventory for a product, starting with oldest inventory first"""
remaining = quantity
# Get all inventory for this product, ordered by id (assuming oldest first)
inventories = db.query(Inventory).filter(
Inventory.product_id == product_id,
Inventory.quantity > 0
).order_by(Inventory.id).all()
for inv in inventories:
if remaining <= 0:
break
if inv.quantity >= remaining:
# This inventory is enough to cover remaining quantity
inv.quantity -= remaining
# Record transaction
transaction = InventoryTransaction(
product_id=product_id,
quantity=-remaining, # Negative for reduction
transaction_type="sale",
reference_id=sale_id,
location=inv.location
)
db.add(transaction)
remaining = 0
else:
# Use all of this inventory and continue to next
remaining -= inv.quantity
# Record transaction
transaction = InventoryTransaction(
product_id=product_id,
quantity=-inv.quantity, # Negative for reduction
transaction_type="sale",
reference_id=sale_id,
location=inv.location
)
db.add(transaction)
inv.quantity = 0
def get_with_items(self, db: Session, id: int) -> Optional[Sale]:
"""Get sale with its items"""
return db.query(Sale).options(
joinedload(Sale.items).joinedload(SaleItem.product)
).filter(Sale.id == id).first()
def get_multi_with_items(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[Sale]:
"""Get multiple sales with their items"""
return db.query(Sale).options(
joinedload(Sale.items).joinedload(SaleItem.product)
).offset(skip).limit(limit).all()
def cancel_sale(self, db: Session, *, id: int) -> Optional[Sale]:
"""Cancel a sale and return items to inventory"""
sale = self.get_with_items(db, id)
if not sale:
return None
if sale.status != "completed":
return sale # Already cancelled or returned
sale.status = "cancelled"
# Return items to inventory
for item in sale.items:
# Find or create inventory at default location
inventory = db.query(Inventory).filter(
Inventory.product_id == item.product_id,
Inventory.location == None # Default location
).first()
if not inventory:
inventory = Inventory(
product_id=item.product_id,
quantity=0,
location=None
)
db.add(inventory)
db.flush()
# Update quantity
inventory.quantity += item.quantity
# Record transaction
transaction = InventoryTransaction(
product_id=item.product_id,
quantity=item.quantity,
transaction_type="return",
reference_id=sale.id,
reason="Sale cancelled"
)
db.add(transaction)
db.commit()
db.refresh(sale)
return sale
def get_total_amount(self, db: Session, *, id: int) -> Decimal:
"""Calculate total amount for a sale"""
result = db.query(
func.sum(SaleItem.quantity * SaleItem.unit_price).label("total")
).filter(
SaleItem.sale_id == id
).scalar()
return result or Decimal("0.00")
sale = CRUDSale(Sale)

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

@ -0,0 +1,56 @@
from typing import Any, Dict, Optional, Union
from sqlalchemy.orm import Session
from app.core.security.password import get_password_hash, verify_password
from app.crud.base import CRUDBase
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
def get_by_email(self, db: Session, *, email: str) -> Optional[User]:
return db.query(User).filter(User.email == email).first()
def create(self, db: Session, *, obj_in: UserCreate) -> User:
db_obj = User(
email=obj_in.email,
hashed_password=get_password_hash(obj_in.password),
full_name=obj_in.full_name,
is_admin=obj_in.is_admin,
is_active=obj_in.is_active
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]
) -> User:
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.model_dump(exclude_unset=True)
if update_data.get("password"):
hashed_password = get_password_hash(update_data["password"])
del update_data["password"]
update_data["hashed_password"] = hashed_password
return super().update(db, db_obj=db_obj, obj_in=update_data)
def authenticate(self, db: Session, *, email: str, password: str) -> Optional[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:
return user.is_active
def is_admin(self, user: User) -> bool:
return user.is_admin
user = CRUDUser(User)

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

@ -0,0 +1,9 @@
# 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.product import Product # noqa
from app.models.category import Category # noqa
from app.models.inventory import Inventory # noqa
from app.models.purchase_order import PurchaseOrder, PurchaseOrderItem # noqa
from app.models.sale import Sale, SaleItem # noqa

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

@ -0,0 +1,13 @@
from typing import Any
from sqlalchemy.ext.declarative import as_declarative, declared_attr
@as_declarative()
class Base:
id: Any
__name__: str
# Generate __tablename__ automatically based on class name
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower()

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

@ -0,0 +1,18 @@
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():
db = SessionLocal()
try:
yield db
finally:
db.close()

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

@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, String, Text
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class Category(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
description = Column(Text, nullable=True)
# Relationships
products = relationship("Product", back_populates="category")

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

@ -0,0 +1,29 @@
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base_class import Base
class Inventory(Base):
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("product.id"), nullable=False)
quantity = Column(Integer, nullable=False, default=0)
location = Column(String, nullable=True)
# Relationships
product = relationship("Product", back_populates="inventory_items")
class InventoryTransaction(Base):
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("product.id"), nullable=False)
quantity = Column(Integer, nullable=False) # Can be positive or negative
transaction_type = Column(String, nullable=False) # purchase, sale, adjustment
reference_id = Column(Integer, nullable=True) # ID of related transaction
reason = Column(String, nullable=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
location = Column(String, nullable=True)
# Relationships
product = relationship("Product")

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

@ -0,0 +1,21 @@
from sqlalchemy import Column, Integer, String, Text, Numeric, ForeignKey
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class Product(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
description = Column(Text, nullable=True)
sku = Column(String, index=True, nullable=True, unique=True)
barcode = Column(String, index=True, nullable=True, unique=True)
unit_price = Column(Numeric(precision=10, scale=2), nullable=False)
cost_price = Column(Numeric(precision=10, scale=2), nullable=False)
category_id = Column(Integer, ForeignKey("category.id"), nullable=True)
# Relationships
category = relationship("Category", back_populates="products")
inventory_items = relationship("Inventory", back_populates="product", cascade="all, delete-orphan")
purchase_order_items = relationship("PurchaseOrderItem", back_populates="product")
sale_items = relationship("SaleItem", back_populates="product")

View File

@ -0,0 +1,31 @@
from sqlalchemy import Column, Integer, String, Text, Numeric, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base_class import Base
class PurchaseOrder(Base):
id = Column(Integer, primary_key=True, index=True)
supplier_name = Column(String, nullable=False)
notes = Column(Text, nullable=True)
status = Column(String, nullable=False, default="pending") # pending, received, cancelled
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
created_by = Column(Integer, ForeignKey("user.id"), nullable=False)
# Relationships
items = relationship("PurchaseOrderItem", back_populates="purchase_order", cascade="all, delete-orphan")
created_by_user = relationship("User", back_populates="purchase_orders")
class PurchaseOrderItem(Base):
id = Column(Integer, primary_key=True, index=True)
purchase_order_id = Column(Integer, ForeignKey("purchaseorder.id"), nullable=False)
product_id = Column(Integer, ForeignKey("product.id"), nullable=False)
quantity = Column(Integer, nullable=False)
unit_price = Column(Numeric(precision=10, scale=2), nullable=False)
# Relationships
purchase_order = relationship("PurchaseOrder", back_populates="items")
product = relationship("Product", back_populates="purchase_order_items")

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

@ -0,0 +1,31 @@
from sqlalchemy import Column, Integer, String, Text, Numeric, ForeignKey, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base_class import Base
class Sale(Base):
id = Column(Integer, primary_key=True, index=True)
customer_name = Column(String, nullable=True)
notes = Column(Text, nullable=True)
status = Column(String, nullable=False, default="completed") # completed, cancelled, returned
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
created_by = Column(Integer, ForeignKey("user.id"), nullable=False)
# Relationships
items = relationship("SaleItem", back_populates="sale", cascade="all, delete-orphan")
created_by_user = relationship("User", back_populates="sales")
class SaleItem(Base):
id = Column(Integer, primary_key=True, index=True)
sale_id = Column(Integer, ForeignKey("sale.id"), nullable=False)
product_id = Column(Integer, ForeignKey("product.id"), nullable=False)
quantity = Column(Integer, nullable=False)
unit_price = Column(Numeric(precision=10, scale=2), nullable=False)
# Relationships
sale = relationship("Sale", back_populates="items")
product = relationship("Product", back_populates="sale_items")

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

@ -0,0 +1,17 @@
from sqlalchemy import Boolean, Column, Integer, String
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class User(Base):
id = Column(Integer, primary_key=True, index=True)
full_name = Column(String, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
is_active = Column(Boolean(), default=True)
is_admin = Column(Boolean(), default=False)
# Relationships
purchase_orders = relationship("PurchaseOrder", back_populates="created_by_user")
sales = relationship("Sale", back_populates="created_by_user")

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

@ -0,0 +1,31 @@
from typing import Optional
from pydantic import BaseModel, Field
# Shared properties
class CategoryBase(BaseModel):
name: str
description: Optional[str] = None
# Properties to receive on category creation
class CategoryCreate(CategoryBase):
pass
# Properties to receive on category update
class CategoryUpdate(CategoryBase):
name: Optional[str] = None
# Properties shared by models returned from API
class CategoryInDBBase(CategoryBase):
id: int
class Config:
from_attributes = True
# Properties to return via API
class Category(CategoryInDBBase):
pass

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

@ -0,0 +1,41 @@
from typing import Optional, List
from pydantic import BaseModel, Field
# Shared properties
class InventoryBase(BaseModel):
product_id: int
quantity: int = Field(..., gt=0)
location: Optional[str] = None
# Properties to receive on inventory creation
class InventoryCreate(InventoryBase):
pass
# Properties to receive on inventory update
class InventoryUpdate(InventoryBase):
product_id: Optional[int] = None
quantity: Optional[int] = None
# Properties shared by models in DB
class InventoryInDBBase(InventoryBase):
id: int
class Config:
from_attributes = True
# Properties to return via API
class Inventory(InventoryInDBBase):
pass
# Properties for inventory adjustment
class InventoryAdjustment(BaseModel):
product_id: int
quantity: int # Can be positive (add) or negative (remove)
reason: str
location: Optional[str] = None

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

@ -0,0 +1,45 @@
from typing import Optional, List
from decimal import Decimal
from pydantic import BaseModel, Field, condecimal
# Shared properties
class ProductBase(BaseModel):
name: str
description: Optional[str] = None
sku: Optional[str] = None
barcode: Optional[str] = None
unit_price: condecimal(decimal_places=2, ge=0) = Field(..., description="Selling price per unit")
cost_price: condecimal(decimal_places=2, ge=0) = Field(..., description="Cost price per unit")
category_id: Optional[int] = None
# Properties to receive on product creation
class ProductCreate(ProductBase):
pass
# Properties to receive on product update
class ProductUpdate(ProductBase):
name: Optional[str] = None
unit_price: Optional[condecimal(decimal_places=2, ge=0)] = None
cost_price: Optional[condecimal(decimal_places=2, ge=0)] = None
# Properties shared by models in DB
class ProductInDBBase(ProductBase):
id: int
class Config:
from_attributes = True
# Properties to return via API
class Product(ProductInDBBase):
pass
# Properties for product with detailed inventory information
class ProductWithInventory(Product):
total_quantity: int
available_quantity: int

View File

@ -0,0 +1,66 @@
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field, condecimal
# Shared properties for purchase order item
class PurchaseOrderItemBase(BaseModel):
product_id: int
quantity: int = Field(..., gt=0)
unit_price: condecimal(decimal_places=2, ge=0)
# Properties for purchase order item creation
class PurchaseOrderItemCreate(PurchaseOrderItemBase):
pass
# Properties for purchase order item in DB
class PurchaseOrderItemInDBBase(PurchaseOrderItemBase):
id: int
purchase_order_id: int
class Config:
from_attributes = True
# Properties to return via API
class PurchaseOrderItem(PurchaseOrderItemInDBBase):
pass
# Shared properties for purchase order
class PurchaseOrderBase(BaseModel):
supplier_name: str
notes: Optional[str] = None
status: str = "pending" # pending, received, cancelled
# Properties for purchase order creation
class PurchaseOrderCreate(PurchaseOrderBase):
items: List[PurchaseOrderItemCreate]
# Properties for purchase order update
class PurchaseOrderUpdate(BaseModel):
supplier_name: Optional[str] = None
notes: Optional[str] = None
status: Optional[str] = None
# Properties for purchase order in DB
class PurchaseOrderInDBBase(PurchaseOrderBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
created_by: int
class Config:
from_attributes = True
# Properties to return via API
class PurchaseOrder(PurchaseOrderInDBBase):
items: List[PurchaseOrderItem]
total_amount: condecimal(decimal_places=2)

66
app/schemas/sale.py Normal file
View File

@ -0,0 +1,66 @@
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, Field, condecimal
# Shared properties for sale item
class SaleItemBase(BaseModel):
product_id: int
quantity: int = Field(..., gt=0)
unit_price: condecimal(decimal_places=2, ge=0)
# Properties for sale item creation
class SaleItemCreate(SaleItemBase):
pass
# Properties for sale item in DB
class SaleItemInDBBase(SaleItemBase):
id: int
sale_id: int
class Config:
from_attributes = True
# Properties to return via API
class SaleItem(SaleItemInDBBase):
pass
# Shared properties for sale
class SaleBase(BaseModel):
customer_name: Optional[str] = None
notes: Optional[str] = None
status: str = "completed" # completed, cancelled, returned
# Properties for sale creation
class SaleCreate(SaleBase):
items: List[SaleItemCreate]
# Properties for sale update
class SaleUpdate(BaseModel):
customer_name: Optional[str] = None
notes: Optional[str] = None
status: Optional[str] = None
# Properties for sale in DB
class SaleInDBBase(SaleBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
created_by: int
class Config:
from_attributes = True
# Properties to return via API
class Sale(SaleInDBBase):
items: List[SaleItem]
total_amount: condecimal(decimal_places=2)

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

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

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

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

34
main.py Normal file
View File

@ -0,0 +1,34 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.api.api_v1.api import api_router
from app.core.health import health_router
app = FastAPI(
title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
description="Small Business Inventory Management System API",
version="0.1.0",
)
# Set all CORS enabled origins
if settings.BACKEND_CORS_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.API_V1_STR)
app.include_router(health_router)
@app.get("/")
async def root():
return {"message": "Welcome to the Small Business Inventory Management System"}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

79
migrations/env.py Normal file
View File

@ -0,0 +1,79 @@
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from app.db.base import Base # noqa
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
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,168 @@
"""Initial migration
Revision ID: 001
Revises:
Create Date: 2023-08-12 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Create user table
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('full_name', sa.String(), nullable=True),
sa.Column('email', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('is_admin', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
op.create_index(op.f('ix_user_full_name'), 'user', ['full_name'], unique=False)
op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False)
# Create category table
op.create_table('category',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_category_id'), 'category', ['id'], unique=False)
op.create_index(op.f('ix_category_name'), 'category', ['name'], unique=False)
# Create product table
op.create_table('product',
sa.Column('id', sa.Integer(), 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('unit_price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('cost_price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['category_id'], ['category.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_product_barcode'), 'product', ['barcode'], unique=True)
op.create_index(op.f('ix_product_id'), 'product', ['id'], unique=False)
op.create_index(op.f('ix_product_name'), 'product', ['name'], unique=False)
op.create_index(op.f('ix_product_sku'), 'product', ['sku'], unique=True)
# Create inventory table
op.create_table('inventory',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('location', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_inventory_id'), 'inventory', ['id'], unique=False)
# Create inventory transaction table
op.create_table('inventorytransaction',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('transaction_type', sa.String(), nullable=False),
sa.Column('reference_id', sa.Integer(), nullable=True),
sa.Column('reason', sa.String(), nullable=True),
sa.Column('timestamp', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('location', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_inventorytransaction_id'), 'inventorytransaction', ['id'], unique=False)
# Create purchase order table
op.create_table('purchaseorder',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('supplier_name', sa.String(), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_purchaseorder_id'), 'purchaseorder', ['id'], unique=False)
# Create purchase order item table
op.create_table('purchaseorderitem',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('purchase_order_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('unit_price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.ForeignKeyConstraint(['purchase_order_id'], ['purchaseorder.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_purchaseorderitem_id'), 'purchaseorderitem', ['id'], unique=False)
# Create sale table
op.create_table('sale',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('customer_name', sa.String(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sale_id'), 'sale', ['id'], unique=False)
# Create sale item table
op.create_table('saleitem',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('sale_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('unit_price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
sa.ForeignKeyConstraint(['sale_id'], ['sale.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_saleitem_id'), 'saleitem', ['id'], unique=False)
def downgrade():
# Drop tables in reverse order of creation
op.drop_index(op.f('ix_saleitem_id'), table_name='saleitem')
op.drop_table('saleitem')
op.drop_index(op.f('ix_sale_id'), table_name='sale')
op.drop_table('sale')
op.drop_index(op.f('ix_purchaseorderitem_id'), table_name='purchaseorderitem')
op.drop_table('purchaseorderitem')
op.drop_index(op.f('ix_purchaseorder_id'), table_name='purchaseorder')
op.drop_table('purchaseorder')
op.drop_index(op.f('ix_inventorytransaction_id'), table_name='inventorytransaction')
op.drop_table('inventorytransaction')
op.drop_index(op.f('ix_inventory_id'), table_name='inventory')
op.drop_table('inventory')
op.drop_index(op.f('ix_product_sku'), table_name='product')
op.drop_index(op.f('ix_product_name'), table_name='product')
op.drop_index(op.f('ix_product_id'), table_name='product')
op.drop_index(op.f('ix_product_barcode'), table_name='product')
op.drop_table('product')
op.drop_index(op.f('ix_category_name'), table_name='category')
op.drop_index(op.f('ix_category_id'), table_name='category')
op.drop_table('category')
op.drop_index(op.f('ix_user_id'), table_name='user')
op.drop_index(op.f('ix_user_full_name'), table_name='user')
op.drop_index(op.f('ix_user_email'), table_name='user')
op.drop_table('user')

View File

@ -0,0 +1,51 @@
"""Add admin user
Revision ID: 002
Revises: 001
Create Date: 2023-08-12 00:01:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, column
import datetime
from passlib.context import CryptContext
# revision identifiers, used by Alembic.
revision = '002'
down_revision = '001'
branch_labels = None
depends_on = None
def upgrade():
# Create a pwd_context object for password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Define the user table for inserting data
user_table = table('user',
column('id', sa.Integer),
column('full_name', sa.String),
column('email', sa.String),
column('hashed_password', sa.String),
column('is_active', sa.Boolean),
column('is_admin', sa.Boolean)
)
# Insert admin user with hashed password
op.bulk_insert(user_table,
[
{
'full_name': 'Admin User',
'email': 'admin@example.com',
'hashed_password': pwd_context.hash('admin123'), # Default password, should be changed
'is_active': True,
'is_admin': True
}
]
)
def downgrade():
# Remove the admin user - find by email
op.execute("DELETE FROM user WHERE email = 'admin@example.com'")

View File

@ -0,0 +1,166 @@
"""Add sample data
Revision ID: 003
Revises: 002
Create Date: 2023-08-12 00:02:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, column
import datetime
from decimal import Decimal
# revision identifiers, used by Alembic.
revision = '003'
down_revision = '002'
branch_labels = None
depends_on = None
def upgrade():
# Define tables for inserting data
category_table = table('category',
column('id', sa.Integer),
column('name', sa.String),
column('description', sa.Text)
)
product_table = table('product',
column('id', sa.Integer),
column('name', sa.String),
column('description', sa.Text),
column('sku', sa.String),
column('barcode', sa.String),
column('unit_price', sa.Numeric),
column('cost_price', sa.Numeric),
column('category_id', sa.Integer)
)
# Insert sample categories
op.bulk_insert(category_table,
[
{
'id': 1,
'name': 'Electronics',
'description': 'Electronic devices and accessories'
},
{
'id': 2,
'name': 'Office Supplies',
'description': 'Stationery and office equipment'
},
{
'id': 3,
'name': 'Furniture',
'description': 'Home and office furniture'
}
]
)
# Insert sample products
op.bulk_insert(product_table,
[
{
'id': 1,
'name': 'Laptop',
'description': 'High-performance laptop for work and gaming',
'sku': 'EL-LAP-001',
'barcode': '1234567890123',
'unit_price': 1299.99,
'cost_price': 899.99,
'category_id': 1
},
{
'id': 2,
'name': 'Wireless Mouse',
'description': 'Ergonomic wireless mouse',
'sku': 'EL-MOU-001',
'barcode': '1234567890124',
'unit_price': 29.99,
'cost_price': 12.50,
'category_id': 1
},
{
'id': 3,
'name': 'Notebook',
'description': 'Premium quality hardcover notebook',
'sku': 'OS-NOT-001',
'barcode': '2234567890123',
'unit_price': 12.99,
'cost_price': 4.75,
'category_id': 2
},
{
'id': 4,
'name': 'Desk Chair',
'description': 'Comfortable office chair with lumbar support',
'sku': 'FN-CHR-001',
'barcode': '3234567890123',
'unit_price': 199.99,
'cost_price': 89.50,
'category_id': 3
},
{
'id': 5,
'name': 'Standing Desk',
'description': 'Adjustable height standing desk',
'sku': 'FN-DSK-001',
'barcode': '3234567890124',
'unit_price': 349.99,
'cost_price': 175.00,
'category_id': 3
}
]
)
# Define inventory table
inventory_table = table('inventory',
column('id', sa.Integer),
column('product_id', sa.Integer),
column('quantity', sa.Integer),
column('location', sa.String)
)
# Insert sample inventory
op.bulk_insert(inventory_table,
[
{
'id': 1,
'product_id': 1,
'quantity': 10,
'location': 'Warehouse A'
},
{
'id': 2,
'product_id': 2,
'quantity': 50,
'location': 'Warehouse A'
},
{
'id': 3,
'product_id': 3,
'quantity': 100,
'location': 'Warehouse B'
},
{
'id': 4,
'product_id': 4,
'quantity': 20,
'location': 'Warehouse C'
},
{
'id': 5,
'product_id': 5,
'quantity': 15,
'location': 'Warehouse C'
}
]
)
def downgrade():
# Delete sample data in reverse order
op.execute("DELETE FROM inventory WHERE id IN (1, 2, 3, 4, 5)")
op.execute("DELETE FROM product WHERE id IN (1, 2, 3, 4, 5)")
op.execute("DELETE FROM category WHERE id IN (1, 2, 3)")

14
pyproject.toml Normal file
View File

@ -0,0 +1,14 @@
[tool.ruff]
line-length = 100
target-version = "py39"
select = [
"E", # pycodestyle errors
"F", # pyflakes
"I", # isort
]
ignore = [
"E501", # line too long (handled by formatter)
]
[tool.ruff.isort]
known-third-party = ["fastapi", "pydantic", "sqlalchemy", "alembic", "jose", "passlib"]

10
requirements.txt Normal file
View File

@ -0,0 +1,10 @@
fastapi>=0.95.0
uvicorn>=0.21.1
sqlalchemy>=2.0.7
alembic>=1.10.2
pydantic>=2.0.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
ruff>=0.0.257
python-dotenv>=1.0.0

10
run.py Normal file
View File

@ -0,0 +1,10 @@
import os
import uvicorn
from pathlib import Path
# Make sure storage directory exists
storage_dir = Path("/app/storage/db")
storage_dir.mkdir(parents=True, exist_ok=True)
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)