diff --git a/README.md b/README.md index e8acfba..648d28f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,157 @@ -# FastAPI Application +# Small Business Inventory Management System -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A FastAPI-based inventory management system designed for small businesses. This API provides comprehensive inventory management capabilities including product management, inventory movement tracking, supplier and category organization, user authentication, and reporting. + +## Features + +- **Product Management**: Create, update, and delete products with details such as SKU, barcode, price, and stock levels +- **Category & Supplier Management**: Organize products by categories and suppliers +- **Inventory Movement Tracking**: Track all inventory changes (stock in, stock out, adjustments, returns) +- **User Authentication**: Secure API access with JWT-based authentication +- **Reports**: Generate inventory insights including low stock alerts, inventory valuation, and movement history +- **Health Monitoring**: Monitor API and database health + +## Tech Stack + +- **Framework**: FastAPI +- **Database**: SQLite with SQLAlchemy ORM +- **Migrations**: Alembic +- **Authentication**: JWT tokens with OAuth2 +- **Validation**: Pydantic models + +## Getting Started + +### Prerequisites + +- Python 3.8+ +- pip (Python package manager) + +### Installation + +1. Clone the repository: + ``` + git clone https://github.com/yourusername/smallbusinessinventorymanagementsystem.git + cd smallbusinessinventorymanagementsystem + ``` + +2. Create a virtual environment: + ``` + python -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +3. Install dependencies: + ``` + pip install -r requirements.txt + ``` + +4. Initialize the database: + ``` + alembic upgrade head + ``` + +5. Run the application: + ``` + uvicorn main:app --reload + ``` + +The API will be available at http://localhost:8000 + +### Environment Variables + +The application uses the following environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| SECRET_KEY | Secret key for JWT token generation | *Random generated key* | +| ACCESS_TOKEN_EXPIRE_MINUTES | JWT token expiration time in minutes | 11520 (8 days) | +| DEBUG | Enable debug mode | False | +| ENVIRONMENT | Environment name (development, production) | development | + +## API Documentation + +Once the application is running, you can access the interactive API documentation at: + +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## API Endpoints + +The API is organized around the following resources: + +### Authentication +- `POST /api/v1/auth/login/access-token` - Get access token +- `POST /api/v1/auth/login/test-token` - Test access token + +### Users +- `GET /api/v1/users` - List all users (admin only) +- `POST /api/v1/users` - Create new user (admin only) +- `GET /api/v1/users/me` - Get current user +- `PUT /api/v1/users/me` - Update current user +- `GET /api/v1/users/{user_id}` - Get user by ID (admin only) +- `PUT /api/v1/users/{user_id}` - Update user (admin only) + +### Products +- `GET /api/v1/products` - List all products +- `POST /api/v1/products` - Create new product +- `GET /api/v1/products/{product_id}` - Get product by ID +- `PUT /api/v1/products/{product_id}` - Update product +- `DELETE /api/v1/products/{product_id}` - Delete product +- `PATCH /api/v1/products/{product_id}/stock` - Update product stock + +### Categories +- `GET /api/v1/categories` - List all categories +- `POST /api/v1/categories` - Create new category +- `GET /api/v1/categories/{category_id}` - Get category by ID +- `PUT /api/v1/categories/{category_id}` - Update category +- `DELETE /api/v1/categories/{category_id}` - Delete category + +### Suppliers +- `GET /api/v1/suppliers` - List all suppliers +- `POST /api/v1/suppliers` - Create new supplier +- `GET /api/v1/suppliers/{supplier_id}` - Get supplier by ID +- `PUT /api/v1/suppliers/{supplier_id}` - Update supplier +- `DELETE /api/v1/suppliers/{supplier_id}` - Delete supplier + +### Inventory Movements +- `GET /api/v1/inventory/movements` - List inventory movements +- `POST /api/v1/inventory/movements` - Create inventory movement +- `GET /api/v1/inventory/movements/{movement_id}` - Get movement by ID +- `DELETE /api/v1/inventory/movements/{movement_id}` - Delete movement + +### Reports +- `GET /api/v1/reports/low-stock` - Get low stock products +- `GET /api/v1/reports/inventory-value` - Get inventory value +- `GET /api/v1/reports/movement-summary` - Get movement summary + +### Health Check +- `GET /api/v1/health` - Check API health + +## Default Admin User + +On first run, the system creates a default admin user: +- Email: admin@example.com +- Password: admin + +**Important:** Change the default admin password in production environments. + +## Development + +### Running Tests + +``` +# TODO: Add test commands +``` + +### Code Formatting + +The project uses Ruff for code formatting and linting: + +``` +ruff check . +ruff format . +``` + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..72d2bd7 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,103 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# SQLite URL example +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..4c0def3 --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,48 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import jwt +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app import crud, models, schemas +from app.core import security +from app.core.config import settings +from app.db.session import get_db + +reusable_oauth2 = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login/access-token") + + +def get_current_user( + db: Session = Depends(get_db), token: str = Depends(reusable_oauth2) +) -> models.User: + """ + Validate the token and return the current user. + """ + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]) + token_data = schemas.TokenPayload(**payload) + except (jwt.JWTError, ValidationError) as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials", + ) from e + user = crud.user.get(db, user_id=token_data.sub) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not crud.user.is_active(user): + raise HTTPException(status_code=400, detail="Inactive user") + return user + + +def get_current_active_superuser( + current_user: models.User = Depends(get_current_user), +) -> models.User: + """ + Validate that the current user is a superuser. + """ + if not crud.user.is_superuser(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The user doesn't have enough privileges", + ) + return current_user diff --git a/app/api/v1/api.py b/app/api/v1/api.py new file mode 100644 index 0000000..10a8f2d --- /dev/null +++ b/app/api/v1/api.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter + +from app.api.v1.endpoints import ( + auth, + categories, + health, + inventory, + products, + reports, + suppliers, + users, +) + +api_router = APIRouter() + +# Include routers from endpoints +api_router.include_router(health.router, prefix="/health", tags=["health"]) +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(products.router, prefix="/products", tags=["products"]) +api_router.include_router(categories.router, prefix="/categories", tags=["categories"]) +api_router.include_router(suppliers.router, prefix="/suppliers", tags=["suppliers"]) +api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"]) +api_router.include_router(reports.router, prefix="/reports", tags=["reports"]) diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..7d2a8ec --- /dev/null +++ b/app/api/v1/endpoints/auth.py @@ -0,0 +1,43 @@ +from datetime import timedelta +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app import crud, models, schemas +from app.api.deps import get_current_user, get_db +from app.core import security +from app.core.config import settings + +router = APIRouter() + + +@router.post("/login/access-token", response_model=schemas.Token) +def login_access_token( + db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends() +) -> Any: + """ + OAuth2 compatible token login, get an access token for future requests + """ + user = crud.user.authenticate(db, email=form_data.username, password=form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Incorrect email or password", + ) + elif not crud.user.is_active(user): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user") + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return { + "access_token": security.create_access_token(user.id, expires_delta=access_token_expires), + "token_type": "bearer", + } + + +@router.post("/login/test-token", response_model=schemas.User) +def test_token(current_user: models.User = Depends(get_current_user)) -> Any: + """ + Test access token + """ + return current_user diff --git a/app/api/v1/endpoints/categories.py b/app/api/v1/endpoints/categories.py new file mode 100644 index 0000000..1222252 --- /dev/null +++ b/app/api/v1/endpoints/categories.py @@ -0,0 +1,119 @@ +from typing import Any, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app import crud, schemas +from app.db.session import get_db + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.Category]) +def read_categories( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + name: Optional[str] = None, +) -> Any: + """ + Retrieve categories with optional filtering by name. + """ + categories = crud.category.get_multi(db, skip=skip, limit=limit, name=name) + return categories + + +@router.post("/", response_model=schemas.Category, status_code=status.HTTP_201_CREATED) +def create_category( + *, + db: Session = Depends(get_db), + category_in: schemas.CategoryCreate, +) -> Any: + """ + Create new category. + """ + # Check if category with the same name already exists + category = crud.category.get_by_name(db, name=category_in.name) + if category: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Category with this name already exists.", + ) + + category = crud.category.create(db, obj_in=category_in) + return category + + +@router.get("/{category_id}", response_model=schemas.Category) +def read_category( + *, + db: Session = Depends(get_db), + category_id: str, +) -> Any: + """ + Get category by ID. + """ + category = crud.category.get(db, category_id=category_id) + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found", + ) + return category + + +@router.put("/{category_id}", response_model=schemas.Category) +def update_category( + *, + db: Session = Depends(get_db), + category_id: str, + category_in: schemas.CategoryUpdate, +) -> Any: + """ + Update a category. + """ + category = crud.category.get(db, category_id=category_id) + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found", + ) + + # Check if trying to update to existing name + if category_in.name and category_in.name != category.name: + existing_category = crud.category.get_by_name(db, name=category_in.name) + if existing_category and existing_category.id != category_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Category with this name already exists", + ) + + category = crud.category.update(db, db_obj=category, obj_in=category_in) + return category + + +@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +def delete_category( + *, + db: Session = Depends(get_db), + category_id: str, +) -> Any: + """ + Delete a category. + """ + category = crud.category.get(db, category_id=category_id) + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Category not found", + ) + + # Check if category has products before deleting + if category.products and len(category.products) > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete category with products. Remove or reassign products first.", + ) + + crud.category.remove(db, category_id=category_id) + return None diff --git a/app/api/v1/endpoints/health.py b/app/api/v1/endpoints/health.py new file mode 100644 index 0000000..ab9c373 --- /dev/null +++ b/app/api/v1/endpoints/health.py @@ -0,0 +1,45 @@ +import os +import time +from typing import Any, Dict + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.db.session import get_db + +router = APIRouter() + + +@router.get("/") +async def health_check(db: Session = Depends(get_db)) -> Dict[str, Any]: + """ + Health check endpoint for the Inventory Management System API. + Checks database connectivity and returns status. + """ + start_time = time.time() + health_data = { + "status": "healthy", + "timestamp": start_time, + "components": { + "database": {"status": "unhealthy", "details": "Could not connect to database"}, + "api": {"status": "healthy", "details": "API is operational"}, + }, + "environment": os.environ.get("ENVIRONMENT", "development"), + } + + # Check database connectivity + try: + # Try to execute a simple query to check database connectivity + db.execute("SELECT 1") + health_data["components"]["database"] = { + "status": "healthy", + "details": "Database connection successful", + } + except Exception as e: + health_data["status"] = "unhealthy" + health_data["components"]["database"] = {"status": "unhealthy", "details": str(e)} + + # Calculate response time + health_data["response_time_ms"] = round((time.time() - start_time) * 1000, 2) + + return health_data diff --git a/app/api/v1/endpoints/inventory.py b/app/api/v1/endpoints/inventory.py new file mode 100644 index 0000000..0f4f8fa --- /dev/null +++ b/app/api/v1/endpoints/inventory.py @@ -0,0 +1,119 @@ +from typing import Any, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app import crud, schemas +from app.db.session import get_db +from app.models.inventory_movement import MovementType + +router = APIRouter() + + +@router.get("/movements/", response_model=List[schemas.InventoryMovementWithProduct]) +def read_inventory_movements( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + product_id: Optional[str] = None, + movement_type: Optional[schemas.MovementTypeEnum] = None, +) -> Any: + """ + Retrieve inventory movements with optional filtering. + """ + if movement_type: + db_movement_type = MovementType(movement_type) + else: + db_movement_type = None + + movements = crud.inventory.get_multi( + db, skip=skip, limit=limit, product_id=product_id, movement_type=db_movement_type + ) + return movements + + +@router.post( + "/movements/", response_model=schemas.InventoryMovement, status_code=status.HTTP_201_CREATED +) +def create_inventory_movement( + *, + db: Session = Depends(get_db), + movement_in: schemas.InventoryMovementCreate, +) -> Any: + """ + Create a new inventory movement (stock in, stock out, adjustment, return). + """ + # Check if product exists + product = crud.product.get(db, product_id=movement_in.product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + + # For stock out, check if there is enough stock + if ( + movement_in.type == schemas.MovementTypeEnum.STOCK_OUT + and product.current_stock < movement_in.quantity + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Insufficient stock for product {product.name}. Available: {product.current_stock}, Requested: {movement_in.quantity}", + ) + + try: + movement = crud.inventory.create(db, obj_in=movement_in) + return movement + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + +@router.get("/movements/{movement_id}", response_model=schemas.InventoryMovementWithProduct) +def read_inventory_movement( + *, + db: Session = Depends(get_db), + movement_id: str, +) -> Any: + """ + Get a specific inventory movement by ID. + """ + movement = crud.inventory.get(db, movement_id=movement_id) + if not movement: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Inventory movement not found", + ) + return movement + + +@router.delete( + "/movements/{movement_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None +) +def delete_inventory_movement( + *, + db: Session = Depends(get_db), + movement_id: str, +) -> Any: + """ + Delete an inventory movement and revert its effect on stock. + Warning: This will alter inventory history and may cause inconsistencies. + Consider using adjustments instead for corrections. + """ + movement = crud.inventory.get(db, movement_id=movement_id) + if not movement: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Inventory movement not found", + ) + + try: + crud.inventory.remove(db, movement_id=movement_id) + return None + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e diff --git a/app/api/v1/endpoints/products.py b/app/api/v1/endpoints/products.py new file mode 100644 index 0000000..ccd58f0 --- /dev/null +++ b/app/api/v1/endpoints/products.py @@ -0,0 +1,186 @@ +from typing import Any, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app import crud, schemas +from app.db.session import get_db + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.Product]) +def read_products( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + name: Optional[str] = None, + category_id: Optional[str] = None, + supplier_id: Optional[str] = None, + is_active: Optional[bool] = None, + low_stock: Optional[bool] = None, +) -> Any: + """ + Retrieve products with optional filtering. + """ + filters = { + "name": name, + "category_id": category_id, + "supplier_id": supplier_id, + "is_active": is_active, + "low_stock": low_stock, + } + + # Remove None values + filters = {k: v for k, v in filters.items() if v is not None} + + products = crud.product.get_multi(db, skip=skip, limit=limit, filters=filters) + return products + + +@router.post("/", response_model=schemas.Product, status_code=status.HTTP_201_CREATED) +def create_product( + *, + db: Session = Depends(get_db), + product_in: schemas.ProductCreate, +) -> Any: + """ + Create new product. + """ + # Check if product with the same SKU already exists + if product_in.sku: + product = crud.product.get_by_sku(db, sku=product_in.sku) + if product: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Product with this SKU already exists.", + ) + + # Check if product with the same barcode already exists + if product_in.barcode: + product = crud.product.get_by_barcode(db, barcode=product_in.barcode) + if product: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Product with this barcode already exists.", + ) + + product = crud.product.create(db, obj_in=product_in) + return product + + +@router.get("/{product_id}", response_model=schemas.ProductWithDetails) +def read_product( + *, + db: Session = Depends(get_db), + product_id: str, +) -> Any: + """ + Get product by ID. + """ + product = crud.product.get(db, product_id=product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + return product + + +@router.put("/{product_id}", response_model=schemas.Product) +def update_product( + *, + db: Session = Depends(get_db), + product_id: str, + product_in: schemas.ProductUpdate, +) -> Any: + """ + Update a product. + """ + product = crud.product.get(db, product_id=product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + + # Check if trying to update to existing SKU + if product_in.sku and product_in.sku != product.sku: + existing_product = crud.product.get_by_sku(db, sku=product_in.sku) + if existing_product and existing_product.id != product_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Product with this SKU already exists", + ) + + # Check if trying to update to existing barcode + if product_in.barcode and product_in.barcode != product.barcode: + existing_product = crud.product.get_by_barcode(db, barcode=product_in.barcode) + if existing_product and existing_product.id != product_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Product with this barcode already exists", + ) + + product = crud.product.update(db, db_obj=product, obj_in=product_in) + return product + + +@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +def delete_product( + *, + db: Session = Depends(get_db), + product_id: str, +) -> Any: + """ + Delete a product. + """ + product = crud.product.get(db, product_id=product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + + # Check if product has inventory movements before deleting + if product.inventory_movements and len(product.inventory_movements) > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete product with inventory movements", + ) + + crud.product.remove(db, product_id=product_id) + return None + + +@router.patch("/{product_id}/stock", response_model=schemas.Product) +def update_product_stock( + *, + db: Session = Depends(get_db), + product_id: str, + stock_update: schemas.StockUpdate, +) -> Any: + """ + Manually update product stock. + """ + product = crud.product.get(db, product_id=product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + + # Create inventory movement record for audit + movement_in = schemas.InventoryMovementCreate( + product_id=product_id, + quantity=stock_update.quantity, + type=schemas.MovementTypeEnum.ADJUSTMENT, + notes=stock_update.notes or "Manual stock adjustment", + ) + + # This would be inside a transaction in a real-world scenario + # Create the movement record and update stock + crud.inventory.create(db, obj_in=movement_in) + product = crud.product.update_stock(db, db_obj=product, quantity=stock_update.quantity) + + return product diff --git a/app/api/v1/endpoints/reports.py b/app/api/v1/endpoints/reports.py new file mode 100644 index 0000000..9990ffc --- /dev/null +++ b/app/api/v1/endpoints/reports.py @@ -0,0 +1,208 @@ +from datetime import datetime, timedelta +from typing import Any, List + +from fastapi import APIRouter, Depends +from sqlalchemy import and_, func +from sqlalchemy.orm import Session + +from app import models, schemas +from app.api import deps +from app.db.session import get_db +from app.models.inventory_movement import MovementType + +router = APIRouter() + + +@router.get("/low-stock", response_model=List[schemas.LowStockProduct]) +def get_low_stock_products( + db: Session = Depends(get_db), + current_user: models.User = Depends(deps.get_current_user), +) -> Any: + """ + Get all products that are at or below their minimum stock level. + """ + # Query products with supplier and category names + query = ( + db.query( + models.Product.id, + models.Product.name, + models.Product.sku, + models.Product.current_stock, + models.Product.min_stock_level, + models.Supplier.name.label("supplier_name"), + models.Category.name.label("category_name"), + ) + .outerjoin(models.Supplier, models.Product.supplier_id == models.Supplier.id) + .outerjoin(models.Category, models.Product.category_id == models.Category.id) + .filter( + and_( + models.Product.is_active, + models.Product.min_stock_level.isnot(None), + models.Product.current_stock <= models.Product.min_stock_level, + ) + ) + .order_by( + # Order by criticality: largest gap between current and min stock first + (models.Product.min_stock_level - models.Product.current_stock).desc() + ) + ) + + # Convert to list of dictionaries + results = [ + { + "id": row.id, + "name": row.name, + "sku": row.sku, + "current_stock": row.current_stock, + "min_stock_level": row.min_stock_level, + "supplier_name": row.supplier_name, + "category_name": row.category_name, + } + for row in query.all() + ] + + return results + + +@router.get("/inventory-value", response_model=schemas.InventoryValueSummary) +def get_inventory_value( + db: Session = Depends(get_db), + current_user: models.User = Depends(deps.get_current_user), +) -> Any: + """ + Get the total value of the inventory and breakdown by category. + """ + # Query all active products + products = ( + db.query( + models.Product.id, + models.Product.name, + models.Product.sku, + models.Product.current_stock, + models.Product.price, + models.Product.category_id, + models.Category.name.label("category_name"), + ) + .outerjoin(models.Category, models.Product.category_id == models.Category.id) + .filter(models.Product.is_active) + .all() + ) + + # Calculate overall inventory value + total_products = len(products) + total_items = sum(p.current_stock for p in products) + total_value = sum(p.current_stock * p.price for p in products) + + # Calculate average item value + average_item_value = 0 + if total_items > 0: + average_item_value = total_value / total_items + + # Group by category + category_values = {} + for p in products: + category_id = p.category_id or "uncategorized" + category_name = p.category_name or "Uncategorized" + + if category_id not in category_values: + category_values[category_id] = { + "id": category_id, + "name": category_name, + "product_count": 0, + "total_items": 0, + "total_value": 0.0, + } + + category_values[category_id]["product_count"] += 1 + category_values[category_id]["total_items"] += p.current_stock + category_values[category_id]["total_value"] += p.current_stock * p.price + + return { + "total_products": total_products, + "total_items": total_items, + "total_value": total_value, + "average_item_value": average_item_value, + "by_category": list(category_values.values()), + } + + +@router.get("/movement-summary", response_model=schemas.MovementSummary) +def get_movement_summary( + period: schemas.TimePeriod = schemas.TimePeriod.MONTH, + db: Session = Depends(get_db), + current_user: models.User = Depends(deps.get_current_user), +) -> Any: + """ + Get a summary of inventory movements for a specific time period. + """ + # Calculate date range based on period + end_date = datetime.utcnow() + + if period == schemas.TimePeriod.DAY: + start_date = end_date - timedelta(days=1) + period_name = "Last 24 hours" + elif period == schemas.TimePeriod.WEEK: + start_date = end_date - timedelta(days=7) + period_name = "Last 7 days" + elif period == schemas.TimePeriod.MONTH: + start_date = end_date - timedelta(days=30) + period_name = "Last 30 days" + elif period == schemas.TimePeriod.YEAR: + start_date = end_date - timedelta(days=365) + period_name = "Last 365 days" + else: # ALL + start_date = datetime(1900, 1, 1) + period_name = "All time" + + # Query movement statistics + stock_in = ( + db.query(func.sum(models.InventoryMovement.quantity)) + .filter( + models.InventoryMovement.type == MovementType.STOCK_IN, + models.InventoryMovement.created_at.between(start_date, end_date), + ) + .scalar() + or 0 + ) + + stock_out = ( + db.query(func.sum(models.InventoryMovement.quantity)) + .filter( + models.InventoryMovement.type == MovementType.STOCK_OUT, + models.InventoryMovement.created_at.between(start_date, end_date), + ) + .scalar() + or 0 + ) + + adjustments = ( + db.query(func.sum(models.InventoryMovement.quantity)) + .filter( + models.InventoryMovement.type == MovementType.ADJUSTMENT, + models.InventoryMovement.created_at.between(start_date, end_date), + ) + .scalar() + or 0 + ) + + returns = ( + db.query(func.sum(models.InventoryMovement.quantity)) + .filter( + models.InventoryMovement.type == MovementType.RETURN, + models.InventoryMovement.created_at.between(start_date, end_date), + ) + .scalar() + or 0 + ) + + # Calculate net change + net_change = stock_in - stock_out + adjustments + returns + + return { + "period": period_name, + "stock_in": int(stock_in), + "stock_out": int(stock_out), + "adjustments": int(adjustments), + "returns": int(returns), + "net_change": int(net_change), + } diff --git a/app/api/v1/endpoints/suppliers.py b/app/api/v1/endpoints/suppliers.py new file mode 100644 index 0000000..cf42962 --- /dev/null +++ b/app/api/v1/endpoints/suppliers.py @@ -0,0 +1,102 @@ +from typing import Any, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app import crud, schemas +from app.db.session import get_db + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.Supplier]) +def read_suppliers( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + name: Optional[str] = None, +) -> Any: + """ + Retrieve suppliers with optional filtering by name. + """ + suppliers = crud.supplier.get_multi(db, skip=skip, limit=limit, name=name) + return suppliers + + +@router.post("/", response_model=schemas.Supplier, status_code=status.HTTP_201_CREATED) +def create_supplier( + *, + db: Session = Depends(get_db), + supplier_in: schemas.SupplierCreate, +) -> Any: + """ + Create new supplier. + """ + supplier = crud.supplier.create(db, obj_in=supplier_in) + return supplier + + +@router.get("/{supplier_id}", response_model=schemas.Supplier) +def read_supplier( + *, + db: Session = Depends(get_db), + supplier_id: str, +) -> Any: + """ + Get supplier by ID. + """ + supplier = crud.supplier.get(db, supplier_id=supplier_id) + if not supplier: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Supplier not found", + ) + return supplier + + +@router.put("/{supplier_id}", response_model=schemas.Supplier) +def update_supplier( + *, + db: Session = Depends(get_db), + supplier_id: str, + supplier_in: schemas.SupplierUpdate, +) -> Any: + """ + Update a supplier. + """ + supplier = crud.supplier.get(db, supplier_id=supplier_id) + if not supplier: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Supplier not found", + ) + + supplier = crud.supplier.update(db, db_obj=supplier, obj_in=supplier_in) + return supplier + + +@router.delete("/{supplier_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +def delete_supplier( + *, + db: Session = Depends(get_db), + supplier_id: str, +) -> Any: + """ + Delete a supplier. + """ + supplier = crud.supplier.get(db, supplier_id=supplier_id) + if not supplier: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Supplier not found", + ) + + # Check if supplier has products before deleting + if supplier.products and len(supplier.products) > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete supplier with products. Remove or reassign products first.", + ) + + crud.supplier.remove(db, supplier_id=supplier_id) + return None diff --git a/app/api/v1/endpoints/users.py b/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..1064cb8 --- /dev/null +++ b/app/api/v1/endpoints/users.py @@ -0,0 +1,127 @@ +from typing import Any, List + +from fastapi import APIRouter, Body, Depends, HTTPException, status +from fastapi.encoders import jsonable_encoder +from pydantic import EmailStr +from sqlalchemy.orm import Session + +from app import crud, models, schemas +from app.api import deps +from app.db.session import get_db + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.User]) +def read_users( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: + """ + Retrieve users. Only for superusers. + """ + users = crud.user.get_multi(db, skip=skip, limit=limit) + return users + + +@router.post("/", response_model=schemas.User, status_code=status.HTTP_201_CREATED) +def create_user( + *, + db: Session = Depends(get_db), + user_in: schemas.UserCreate, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: + """ + Create new user. Only for superusers. + """ + user = crud.user.get_by_email(db, email=user_in.email) + if user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A user with this email already exists in the system.", + ) + user = crud.user.create(db, obj_in=user_in) + return user + + +@router.get("/me", response_model=schemas.User) +def read_user_me( + current_user: models.User = Depends(deps.get_current_user), +) -> Any: + """ + Get current user. + """ + return current_user + + +@router.put("/me", response_model=schemas.User) +def update_user_me( + *, + db: Session = Depends(get_db), + full_name: str = Body(None), + email: EmailStr = Body(None), + password: str = Body(None), + current_user: models.User = Depends(deps.get_current_user), +) -> Any: + """ + Update own user. + """ + current_user_data = jsonable_encoder(current_user) + user_in = schemas.UserUpdate(**current_user_data) + if full_name is not None: + user_in.full_name = full_name + if email is not None: + if email != current_user.email: + # Check if email is already taken + existing_user = crud.user.get_by_email(db, email=email) + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Email already registered", + ) + user_in.email = email + if password is not None: + user_in.password = password + user = crud.user.update(db, db_obj=current_user, obj_in=user_in) + return user + + +@router.get("/{user_id}", response_model=schemas.User) +def read_user_by_id( + user_id: str, + current_user: models.User = Depends(deps.get_current_active_superuser), + db: Session = Depends(get_db), +) -> Any: + """ + Get a specific user by id. Only for superusers. + """ + user = crud.user.get(db, user_id=user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + return user + + +@router.put("/{user_id}", response_model=schemas.User) +def update_user( + *, + db: Session = Depends(get_db), + user_id: str, + user_in: schemas.UserUpdate, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: + """ + Update a user. Only for superusers. + """ + user = crud.user.get(db, user_id=user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + user = crud.user.update(db, db_obj=user, obj_in=user_in) + return user diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..0d8eb1f --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,40 @@ +import os +import secrets +from pathlib import Path + +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + # API configuration + API_V1_STR: str = "/api/v1" + SECRET_KEY: str = os.getenv("SECRET_KEY", secrets.token_urlsafe(32)) + ACCESS_TOKEN_EXPIRE_MINUTES: int = int( + os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", 60 * 24 * 8) + ) # 8 days + + # Project configuration + PROJECT_NAME: str = "Small Business Inventory Management System" + PROJECT_DESCRIPTION: str = "API for managing inventory for small businesses" + PROJECT_VERSION: str = "0.1.0" + + # Database configuration + DB_DIR: Path = Path("/app") / "storage" / "db" + + @field_validator("DB_DIR") + @classmethod + def create_db_dir(cls, v): + v.mkdir(parents=True, exist_ok=True) + return v + + # Debug mode + DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true" + + # CORS configuration + BACKEND_CORS_ORIGINS: list[str] = ["*"] + + model_config = SettingsConfigDict(case_sensitive=True) + + +settings = Settings() diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..6f2119b --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,38 @@ +from datetime import datetime, timedelta +from typing import Any, Optional, Union + +from jose import jwt +from passlib.context import CryptContext + +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +ALGORITHM = "HS256" + + +def create_access_token(subject: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str: + """ + Create a JWT access token. + """ + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + Verify a password against a hash. + """ + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """ + Hash a password for storing. + """ + return pwd_context.hash(password) diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..0477dfe --- /dev/null +++ b/app/crud/__init__.py @@ -0,0 +1,49 @@ +from app.crud.product import ( # noqa + get as get_product, + get_by_sku as get_product_by_sku, + get_by_barcode as get_product_by_barcode, + get_multi as get_products, + create as create_product, + update as update_product, + remove as remove_product, + update_stock as update_product_stock, +) + +from app.crud.category import ( # noqa + get as get_category, + get_by_name as get_category_by_name, + get_multi as get_categories, + create as create_category, + update as update_category, + remove as remove_category, +) + +from app.crud.supplier import ( # noqa + get as get_supplier, + get_by_name as get_supplier_by_name, + get_multi as get_suppliers, + create as create_supplier, + update as update_supplier, + remove as remove_supplier, +) + +from app.crud.inventory import ( # noqa + get as get_inventory_movement, + get_multi as get_inventory_movements, + create as create_inventory_movement, + remove as remove_inventory_movement, +) + +from app.crud.user import ( # noqa + get as get_user, + get_by_email as get_user_by_email, + get_multi as get_users, + create as create_user, + update as update_user, + authenticate as authenticate_user, + is_active as is_user_active, + is_superuser as is_user_superuser, +) + +# Re-export the module level imports as object-like access +from app.crud import product, category, supplier, inventory, user # noqa diff --git a/app/crud/category.py b/app/crud/category.py new file mode 100644 index 0000000..ef1edc2 --- /dev/null +++ b/app/crud/category.py @@ -0,0 +1,81 @@ +from typing import Any, Dict, List, Optional, Union + +from fastapi.encoders import jsonable_encoder +from sqlalchemy.orm import Session + +from app.models.category import Category +from app.schemas.category import CategoryCreate, CategoryUpdate +from app.utils.uuid import generate_uuid + + +def get(db: Session, category_id: str) -> Optional[Category]: + """ + Get a category by ID. + """ + return db.query(Category).filter(Category.id == category_id).first() + + +def get_by_name(db: Session, name: str) -> Optional[Category]: + """ + Get a category by name. + """ + return db.query(Category).filter(Category.name == name).first() + + +def get_multi( + db: Session, *, skip: int = 0, limit: int = 100, name: Optional[str] = None +) -> List[Category]: + """ + Get multiple categories with optional filtering by name. + """ + query = db.query(Category) + + if name: + query = query.filter(Category.name.ilike(f"%{name}%")) + + return query.offset(skip).limit(limit).all() + + +def create(db: Session, *, obj_in: CategoryCreate) -> Category: + """ + Create a new category. + """ + obj_in_data = jsonable_encoder(obj_in) + db_obj = Category(**obj_in_data, id=generate_uuid()) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def update( + db: Session, *, db_obj: Category, obj_in: Union[CategoryUpdate, Dict[str, Any]] +) -> Category: + """ + Update a category. + """ + obj_data = jsonable_encoder(db_obj) + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + + for field in obj_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def remove(db: Session, *, category_id: str) -> Optional[Category]: + """ + Delete a category. + """ + obj = db.query(Category).get(category_id) + if obj: + db.delete(obj) + db.commit() + return obj diff --git a/app/crud/inventory.py b/app/crud/inventory.py new file mode 100644 index 0000000..b2167fd --- /dev/null +++ b/app/crud/inventory.py @@ -0,0 +1,121 @@ +from typing import List, Optional + +from fastapi.encoders import jsonable_encoder +from sqlalchemy import desc +from sqlalchemy.orm import Session + +from app.models.inventory_movement import InventoryMovement, MovementType +from app.models.product import Product +from app.schemas.inventory import InventoryMovementCreate +from app.utils.uuid import generate_uuid + + +def get(db: Session, movement_id: str) -> Optional[InventoryMovement]: + """ + Get an inventory movement by ID. + """ + return db.query(InventoryMovement).filter(InventoryMovement.id == movement_id).first() + + +def get_multi( + db: Session, + *, + skip: int = 0, + limit: int = 100, + product_id: Optional[str] = None, + movement_type: Optional[MovementType] = None, +) -> List[InventoryMovement]: + """ + Get multiple inventory movements with optional filtering. + """ + query = db.query(InventoryMovement) + + if product_id: + query = query.filter(InventoryMovement.product_id == product_id) + + if movement_type: + query = query.filter(InventoryMovement.type == movement_type) + + # Order by most recent first + query = query.order_by(desc(InventoryMovement.created_at)) + + return query.offset(skip).limit(limit).all() + + +def create( + db: Session, *, obj_in: InventoryMovementCreate, created_by: Optional[str] = None +) -> InventoryMovement: + """ + Create a new inventory movement and update product stock. + """ + obj_in_data = jsonable_encoder(obj_in) + db_obj = InventoryMovement(**obj_in_data, id=generate_uuid(), created_by=created_by) + + # Start transaction + try: + # Add movement record + db.add(db_obj) + + # Update product stock based on movement type + product = ( + db.query(Product).filter(Product.id == obj_in.product_id).with_for_update().first() + ) + if not product: + raise ValueError(f"Product with ID {obj_in.product_id} not found") + + if obj_in.type in [MovementType.STOCK_IN, MovementType.RETURN]: + product.current_stock += obj_in.quantity + elif obj_in.type == MovementType.STOCK_OUT: + if product.current_stock < obj_in.quantity: + raise ValueError( + f"Insufficient stock for product {product.name}. Available: {product.current_stock}, Requested: {obj_in.quantity}" + ) + product.current_stock -= obj_in.quantity + elif obj_in.type == MovementType.ADJUSTMENT: + # For adjustments, the quantity value is the delta (can be positive or negative) + product.current_stock += obj_in.quantity + + db.add(product) + db.commit() + db.refresh(db_obj) + return db_obj + except Exception as e: + db.rollback() + raise e + + +def remove(db: Session, *, movement_id: str) -> Optional[InventoryMovement]: + """ + Delete an inventory movement and revert the stock change. + This is generally not recommended for production use as it alters inventory history. + """ + movement = db.query(InventoryMovement).filter(InventoryMovement.id == movement_id).first() + if not movement: + return None + + # Start transaction + try: + # Get the product and prepare to revert the stock change + product = ( + db.query(Product).filter(Product.id == movement.product_id).with_for_update().first() + ) + if not product: + raise ValueError(f"Product with ID {movement.product_id} not found") + + # Revert the stock change based on movement type + if movement.type in [MovementType.STOCK_IN, MovementType.RETURN]: + product.current_stock -= movement.quantity + elif movement.type == MovementType.STOCK_OUT: + product.current_stock += movement.quantity + elif movement.type == MovementType.ADJUSTMENT: + # For adjustments, reverse the quantity effect + product.current_stock -= movement.quantity + + # Delete the movement + db.delete(movement) + db.add(product) + db.commit() + return movement + except Exception as e: + db.rollback() + raise e diff --git a/app/crud/product.py b/app/crud/product.py new file mode 100644 index 0000000..0f1d53c --- /dev/null +++ b/app/crud/product.py @@ -0,0 +1,109 @@ +from typing import Any, Dict, List, Optional, Union + +from fastapi.encoders import jsonable_encoder +from sqlalchemy.orm import Session + +from app.models.product import Product +from app.schemas.product import ProductCreate, ProductUpdate +from app.utils.uuid import generate_uuid + + +def get(db: Session, product_id: str) -> Optional[Product]: + """ + Get a product by ID. + """ + return db.query(Product).filter(Product.id == product_id).first() + + +def get_by_sku(db: Session, sku: str) -> Optional[Product]: + """ + Get a product by SKU. + """ + return db.query(Product).filter(Product.sku == sku).first() + + +def get_by_barcode(db: Session, barcode: str) -> Optional[Product]: + """ + Get a product by barcode. + """ + return db.query(Product).filter(Product.barcode == barcode).first() + + +def get_multi( + db: Session, *, skip: int = 0, limit: int = 100, filters: Optional[Dict[str, Any]] = None +) -> List[Product]: + """ + Get multiple products with optional filtering. + """ + query = db.query(Product) + + # Apply filters if provided + if filters: + if "name" in filters and filters["name"]: + query = query.filter(Product.name.ilike(f"%{filters['name']}%")) + if "category_id" in filters and filters["category_id"]: + query = query.filter(Product.category_id == filters["category_id"]) + if "supplier_id" in filters and filters["supplier_id"]: + query = query.filter(Product.supplier_id == filters["supplier_id"]) + if "is_active" in filters and filters["is_active"] is not None: + query = query.filter(Product.is_active == filters["is_active"]) + if "low_stock" in filters and filters["low_stock"] is True: + query = query.filter(Product.current_stock <= Product.min_stock_level) + + return query.offset(skip).limit(limit).all() + + +def create(db: Session, *, obj_in: ProductCreate) -> Product: + """ + Create a new product. + """ + obj_in_data = jsonable_encoder(obj_in) + db_obj = Product(**obj_in_data, id=generate_uuid()) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def update( + db: Session, *, db_obj: Product, obj_in: Union[ProductUpdate, Dict[str, Any]] +) -> Product: + """ + Update a product. + """ + obj_data = jsonable_encoder(db_obj) + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + + for field in obj_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def remove(db: Session, *, product_id: str) -> Optional[Product]: + """ + Delete a product. + """ + obj = db.query(Product).get(product_id) + if obj: + db.delete(obj) + db.commit() + return obj + + +def update_stock(db: Session, *, db_obj: Product, quantity: int) -> Product: + """ + Update product stock quantity. + """ + db_obj.current_stock += quantity + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj diff --git a/app/crud/supplier.py b/app/crud/supplier.py new file mode 100644 index 0000000..bc2faef --- /dev/null +++ b/app/crud/supplier.py @@ -0,0 +1,81 @@ +from typing import Any, Dict, List, Optional, Union + +from fastapi.encoders import jsonable_encoder +from sqlalchemy.orm import Session + +from app.models.supplier import Supplier +from app.schemas.supplier import SupplierCreate, SupplierUpdate +from app.utils.uuid import generate_uuid + + +def get(db: Session, supplier_id: str) -> Optional[Supplier]: + """ + Get a supplier by ID. + """ + return db.query(Supplier).filter(Supplier.id == supplier_id).first() + + +def get_by_name(db: Session, name: str) -> Optional[Supplier]: + """ + Get a supplier by name (exact match). + """ + return db.query(Supplier).filter(Supplier.name == name).first() + + +def get_multi( + db: Session, *, skip: int = 0, limit: int = 100, name: Optional[str] = None +) -> List[Supplier]: + """ + Get multiple suppliers with optional filtering by name. + """ + query = db.query(Supplier) + + if name: + query = query.filter(Supplier.name.ilike(f"%{name}%")) + + return query.offset(skip).limit(limit).all() + + +def create(db: Session, *, obj_in: SupplierCreate) -> Supplier: + """ + Create a new supplier. + """ + obj_in_data = jsonable_encoder(obj_in) + db_obj = Supplier(**obj_in_data, id=generate_uuid()) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def update( + db: Session, *, db_obj: Supplier, obj_in: Union[SupplierUpdate, Dict[str, Any]] +) -> Supplier: + """ + Update a supplier. + """ + obj_data = jsonable_encoder(db_obj) + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + + for field in obj_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def remove(db: Session, *, supplier_id: str) -> Optional[Supplier]: + """ + Delete a supplier. + """ + obj = db.query(Supplier).get(supplier_id) + if obj: + db.delete(obj) + db.commit() + return obj diff --git a/app/crud/user.py b/app/crud/user.py new file mode 100644 index 0000000..83a7a80 --- /dev/null +++ b/app/crud/user.py @@ -0,0 +1,97 @@ +from typing import Any, Dict, List, Optional, Union + +from sqlalchemy.orm import Session + +from app.core.security import get_password_hash, verify_password +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate +from app.utils.uuid import generate_uuid + + +def get(db: Session, user_id: str) -> Optional[User]: + """ + Get a user by ID. + """ + return db.query(User).filter(User.id == user_id).first() + + +def get_by_email(db: Session, email: str) -> Optional[User]: + """ + Get a user by email. + """ + return db.query(User).filter(User.email == email).first() + + +def get_multi(db: Session, *, skip: int = 0, limit: int = 100) -> List[User]: + """ + Get multiple users. + """ + return db.query(User).offset(skip).limit(limit).all() + + +def create(db: Session, *, obj_in: UserCreate) -> User: + """ + Create a new user. + """ + db_obj = User( + id=generate_uuid(), + email=obj_in.email, + hashed_password=get_password_hash(obj_in.password), + full_name=obj_in.full_name, + is_superuser=obj_in.is_superuser, + is_active=obj_in.is_active, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def update(db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]) -> User: + """ + Update a user. + """ + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + + if "password" in update_data and update_data["password"]: + hashed_password = get_password_hash(update_data["password"]) + del update_data["password"] + update_data["hashed_password"] = hashed_password + + for field in update_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def authenticate(db: Session, *, email: str, password: str) -> Optional[User]: + """ + Authenticate a user. + """ + user = get_by_email(db, email=email) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user + + +def is_active(user: User) -> bool: + """ + Check if a user is active. + """ + return user.is_active + + +def is_superuser(user: User) -> bool: + """ + Check if a user is a superuser. + """ + return user.is_superuser diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..860e542 --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,3 @@ +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() diff --git a/app/db/base_class.py b/app/db/base_class.py new file mode 100644 index 0000000..a3bcca4 --- /dev/null +++ b/app/db/base_class.py @@ -0,0 +1,18 @@ +import re +from typing import Any + +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import as_declarative + + +@as_declarative() +class Base: + id: Any + __name__: str + + # Generate __tablename__ automatically based on class name + @declared_attr + def __tablename__(self) -> str: + # Convert CamelCase to snake_case + name = re.sub(r"(? None: + """ + Initialize the database with default data. + """ + # Create default admin user if it doesn't exist + user = crud.user.get_by_email(db, email="admin@example.com") + if not user: + user_in = schemas.UserCreate( + email="admin@example.com", + password="admin", + full_name="Default Admin", + is_superuser=True, + ) + user = crud.user.create(db, obj_in=user_in) + + # Add other default data as needed + pass diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..05407a1 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,25 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import settings + +# Ensure DB directory exists +DB_DIR = settings.DB_DIR +DB_DIR.mkdir(parents=True, exist_ok=True) + +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite" + +engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db() -> Session: + """ + Dependency for getting a database session. + """ + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..b31bf21 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,5 @@ +from app.models.category import Category +from app.models.inventory_movement import InventoryMovement, MovementType +from app.models.product import Product +from app.models.supplier import Supplier +from app.models.user import User diff --git a/app/models/category.py b/app/models/category.py new file mode 100644 index 0000000..89e0558 --- /dev/null +++ b/app/models/category.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, DateTime, String, Text +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.db.base import Base + + +class Category(Base): + __tablename__ = "categories" + + id = Column(String, primary_key=True, index=True) + name = Column(String, unique=True, index=True, nullable=False) + description = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + products = relationship("Product", back_populates="category") diff --git a/app/models/inventory_movement.py b/app/models/inventory_movement.py new file mode 100644 index 0000000..159131d --- /dev/null +++ b/app/models/inventory_movement.py @@ -0,0 +1,31 @@ +import enum + +from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.db.base import Base + + +class MovementType(str, enum.Enum): + STOCK_IN = "STOCK_IN" + STOCK_OUT = "STOCK_OUT" + ADJUSTMENT = "ADJUSTMENT" + RETURN = "RETURN" + + +class InventoryMovement(Base): + __tablename__ = "inventory_movements" + + id = Column(String, primary_key=True, index=True) + product_id = Column(String, ForeignKey("products.id"), nullable=False) + quantity = Column(Integer, nullable=False) + type = Column(Enum(MovementType), nullable=False) + reference = Column(String, nullable=True) + notes = Column(Text, nullable=True) + unit_price = Column(Float, nullable=True) + created_by = Column(String, ForeignKey("users.id"), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + product = relationship("Product", back_populates="inventory_movements") diff --git a/app/models/product.py b/app/models/product.py new file mode 100644 index 0000000..782daa1 --- /dev/null +++ b/app/models/product.py @@ -0,0 +1,39 @@ +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Integer, + String, + Text, +) +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.db.base import Base + + +class Product(Base): + __tablename__ = "products" + + id = Column(String, primary_key=True, index=True) + name = Column(String, index=True, nullable=False) + description = Column(Text, nullable=True) + sku = Column(String, unique=True, index=True, nullable=True) + barcode = Column(String, unique=True, index=True, nullable=True) + price = Column(Float, nullable=False, default=0.0) + cost_price = Column(Float, nullable=True) + current_stock = Column(Integer, nullable=False, default=0) + min_stock_level = Column(Integer, nullable=True) + max_stock_level = Column(Integer, nullable=True) + is_active = Column(Boolean, default=True) + category_id = Column(String, ForeignKey("categories.id"), nullable=True) + supplier_id = Column(String, ForeignKey("suppliers.id"), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + category = relationship("Category", back_populates="products") + supplier = relationship("Supplier", back_populates="products") + inventory_movements = relationship("InventoryMovement", back_populates="product") diff --git a/app/models/supplier.py b/app/models/supplier.py new file mode 100644 index 0000000..39e86aa --- /dev/null +++ b/app/models/supplier.py @@ -0,0 +1,21 @@ +from sqlalchemy import Column, DateTime, String, Text +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.db.base import Base + + +class Supplier(Base): + __tablename__ = "suppliers" + + id = Column(String, primary_key=True, index=True) + name = Column(String, index=True, nullable=False) + contact_name = Column(String, nullable=True) + email = Column(String, nullable=True) + phone = Column(String, nullable=True) + address = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + products = relationship("Product", back_populates="supplier") diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..8952a91 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,17 @@ +from sqlalchemy import Boolean, Column, DateTime, String +from sqlalchemy.sql import func + +from app.db.base import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(String, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) + full_name = Column(String, nullable=True) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..a762216 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,24 @@ +from app.schemas.category import Category, CategoryCreate, CategoryUpdate +from app.schemas.inventory import ( + InventoryMovement, + InventoryMovementCreate, + InventoryMovementWithProduct, + MovementTypeEnum, +) +from app.schemas.product import ( + Product, + ProductCreate, + ProductUpdate, + ProductWithDetails, + StockUpdate, +) +from app.schemas.reports import ( + CategoryValue, + InventoryValueSummary, + LowStockProduct, + MovementSummary, + ProductValue, + TimePeriod, +) +from app.schemas.supplier import Supplier, SupplierCreate, SupplierUpdate +from app.schemas.user import Token, TokenPayload, User, UserCreate, UserUpdate diff --git a/app/schemas/category.py b/app/schemas/category.py new file mode 100644 index 0000000..ee312d6 --- /dev/null +++ b/app/schemas/category.py @@ -0,0 +1,36 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +# Shared properties +class CategoryBase(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + + +# Properties to receive via API on creation +class CategoryCreate(CategoryBase): + name: str + + +# Properties to receive via API on update +class CategoryUpdate(CategoryBase): + pass + + +# Properties to return via API +class CategoryInDB(CategoryBase): + id: str + name: str + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Additional properties to return via API +class Category(CategoryInDB): + pass diff --git a/app/schemas/inventory.py b/app/schemas/inventory.py new file mode 100644 index 0000000..4544436 --- /dev/null +++ b/app/schemas/inventory.py @@ -0,0 +1,75 @@ +from datetime import datetime +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + + +class MovementTypeEnum(str, Enum): + STOCK_IN = "STOCK_IN" + STOCK_OUT = "STOCK_OUT" + ADJUSTMENT = "ADJUSTMENT" + RETURN = "RETURN" + + +# Shared properties +class InventoryMovementBase(BaseModel): + product_id: Optional[str] = None + quantity: Optional[int] = None + type: Optional[MovementTypeEnum] = None + reference: Optional[str] = None + notes: Optional[str] = None + unit_price: Optional[float] = None + + +# Properties to receive via API on creation +class InventoryMovementCreate(InventoryMovementBase): + product_id: str + quantity: int + type: MovementTypeEnum + + +# Properties to receive via API on update +class InventoryMovementUpdate(InventoryMovementBase): + pass + + +# Properties to return via API +class InventoryMovementInDB(InventoryMovementBase): + id: str + product_id: str + quantity: int + type: MovementTypeEnum + created_by: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + + +# Additional properties to return via API +class InventoryMovement(InventoryMovementInDB): + pass + + +# Inventory movement with product details +class InventoryMovementWithProduct(InventoryMovement): + product: "ProductMinimal" + + class Config: + from_attributes = True + + +# Minimal product representation for movement response +class ProductMinimal(BaseModel): + id: str + name: str + sku: Optional[str] = None + current_stock: int + + class Config: + from_attributes = True + + +# Update recursive forward references +InventoryMovementWithProduct.model_rebuild() diff --git a/app/schemas/product.py b/app/schemas/product.py new file mode 100644 index 0000000..6908002 --- /dev/null +++ b/app/schemas/product.py @@ -0,0 +1,84 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +# Shared properties +class ProductBase(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + sku: Optional[str] = None + barcode: Optional[str] = None + price: Optional[float] = None + cost_price: Optional[float] = None + min_stock_level: Optional[int] = None + max_stock_level: Optional[int] = None + is_active: Optional[bool] = True + category_id: Optional[str] = None + supplier_id: Optional[str] = None + + +# Properties to receive via API on creation +class ProductCreate(ProductBase): + name: str + price: float + + +# Properties to receive via API on update +class ProductUpdate(ProductBase): + pass + + +# Properties for stock update +class StockUpdate(BaseModel): + quantity: int + notes: Optional[str] = None + + +# Properties to return via API +class ProductInDB(ProductBase): + id: str + name: str + current_stock: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Additional properties to return via API +class Product(ProductInDB): + pass + + +# Properties for product with category and supplier details +class ProductWithDetails(Product): + category: Optional["CategoryMinimal"] = None + supplier: Optional["SupplierMinimal"] = None + + class Config: + from_attributes = True + + +# Minimal category representation for product response +class CategoryMinimal(BaseModel): + id: str + name: str + + class Config: + from_attributes = True + + +# Minimal supplier representation for product response +class SupplierMinimal(BaseModel): + id: str + name: str + + class Config: + from_attributes = True + + +# Update recursive forward references +ProductWithDetails.model_rebuild() diff --git a/app/schemas/reports.py b/app/schemas/reports.py new file mode 100644 index 0000000..190acba --- /dev/null +++ b/app/schemas/reports.py @@ -0,0 +1,75 @@ +from enum import Enum +from typing import List, Optional + +from pydantic import BaseModel + + +# Low stock product model +class LowStockProduct(BaseModel): + id: str + name: str + sku: Optional[str] = None + current_stock: int + min_stock_level: Optional[int] = None + supplier_name: Optional[str] = None + category_name: Optional[str] = None + + class Config: + from_attributes = True + + +# Product value model +class ProductValue(BaseModel): + id: str + name: str + sku: Optional[str] = None + current_stock: int + price: float + total_value: float + + class Config: + from_attributes = True + + +# Inventory value summary +class InventoryValueSummary(BaseModel): + total_products: int + total_items: int + total_value: float + average_item_value: float + by_category: List["CategoryValue"] = [] + + +# Category value model +class CategoryValue(BaseModel): + id: str + name: str + product_count: int + total_items: int + total_value: float + + class Config: + from_attributes = True + + +# Time period enum for inventory movement reports +class TimePeriod(str, Enum): + DAY = "day" + WEEK = "week" + MONTH = "month" + YEAR = "year" + ALL = "all" + + +# Inventory movement summary model +class MovementSummary(BaseModel): + period: str + stock_in: int + stock_out: int + adjustments: int + returns: int + net_change: int + + +# Update recursive references +InventoryValueSummary.model_rebuild() diff --git a/app/schemas/supplier.py b/app/schemas/supplier.py new file mode 100644 index 0000000..0de24f9 --- /dev/null +++ b/app/schemas/supplier.py @@ -0,0 +1,39 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr + + +# Shared properties +class SupplierBase(BaseModel): + name: Optional[str] = None + contact_name: Optional[str] = None + email: Optional[EmailStr] = None + phone: Optional[str] = None + address: Optional[str] = None + + +# Properties to receive via API on creation +class SupplierCreate(SupplierBase): + name: str + + +# Properties to receive via API on update +class SupplierUpdate(SupplierBase): + pass + + +# Properties to return via API +class SupplierInDB(SupplierBase): + id: str + name: str + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Additional properties to return via API +class Supplier(SupplierInDB): + pass diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..c61d8e0 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,49 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr + + +# Shared properties +class UserBase(BaseModel): + email: Optional[EmailStr] = None + is_active: Optional[bool] = True + is_superuser: Optional[bool] = False + full_name: Optional[str] = None + + +# Properties to receive via API on creation +class UserCreate(UserBase): + email: EmailStr + password: str + + +# Properties to receive via API on update +class UserUpdate(UserBase): + password: Optional[str] = None + + +# Properties to return via API +class UserInDB(UserBase): + id: str + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Additional properties to return via API +class User(UserInDB): + pass + + +# Properties for token +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenPayload(BaseModel): + sub: Optional[str] = None + exp: Optional[int] = None diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..6c88fb2 --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1 @@ +from app.utils.uuid import generate_uuid diff --git a/app/utils/uuid.py b/app/utils/uuid.py new file mode 100644 index 0000000..c6a1382 --- /dev/null +++ b/app/utils/uuid.py @@ -0,0 +1,6 @@ +import uuid + + +def generate_uuid() -> str: + """Generate a random UUID and return it as a string.""" + return str(uuid.uuid4()) diff --git a/main.py b/main.py new file mode 100644 index 0000000..5145317 --- /dev/null +++ b/main.py @@ -0,0 +1,45 @@ +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1.api import api_router +from app.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, + description=settings.PROJECT_DESCRIPTION, + version=settings.PROJECT_VERSION, + openapi_url="/openapi.json", + docs_url="/docs", + redoc_url="/redoc", +) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(api_router, prefix=settings.API_V1_STR) + + +# Root endpoint +@app.get("/") +async def root(): + """ + Root endpoint for the Inventory Management System API. + """ + return { + "title": settings.PROJECT_NAME, + "description": settings.PROJECT_DESCRIPTION, + "docs": f"{settings.API_V1_STR}/docs", + "health": f"{settings.API_V1_STR}/health", + } + + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4eac512 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,89 @@ +import os +import sys +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +# Add the project directory to the Python path +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +# Import the SQLAlchemy models +from app.core.config import settings +from app.db.base_model import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Set the SQLAlchemy URL +config.set_main_option("sqlalchemy.url", str(settings.DB_DIR).replace("\\", "/")) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, # Use batch mode for SQLite + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=connection.dialect.name == "sqlite", # Use batch mode for SQLite + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..37d0cac --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/migrations/versions/0001_initial.py b/migrations/versions/0001_initial.py new file mode 100644 index 0000000..98ce281 --- /dev/null +++ b/migrations/versions/0001_initial.py @@ -0,0 +1,162 @@ +"""Initial database structure + +Revision ID: 0001 +Revises: +Create Date: 2023-11-18 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Create users table + op.create_table( + "users", + sa.Column("id", sa.String(), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("hashed_password", sa.String(), nullable=False), + sa.Column("full_name", sa.String(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=True, default=True), + sa.Column("is_superuser", sa.Boolean(), nullable=True, default=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)") + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False) + + # Create categories table + op.create_table( + "categories", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)") + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_categories_id"), "categories", ["id"], unique=False) + op.create_index(op.f("ix_categories_name"), "categories", ["name"], unique=True) + + # Create suppliers table + op.create_table( + "suppliers", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("contact_name", sa.String(), nullable=True), + sa.Column("email", sa.String(), nullable=True), + sa.Column("phone", sa.String(), nullable=True), + sa.Column("address", sa.Text(), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)") + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_suppliers_id"), "suppliers", ["id"], unique=False) + op.create_index(op.f("ix_suppliers_name"), "suppliers", ["name"], unique=False) + + # Create products table + op.create_table( + "products", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("sku", sa.String(), nullable=True), + sa.Column("barcode", sa.String(), nullable=True), + sa.Column("price", sa.Float(), nullable=False, default=0.0), + sa.Column("cost_price", sa.Float(), nullable=True), + sa.Column("current_stock", sa.Integer(), nullable=False, default=0), + sa.Column("min_stock_level", sa.Integer(), nullable=True), + sa.Column("max_stock_level", sa.Integer(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=True, default=True), + sa.Column("category_id", sa.String(), nullable=True), + sa.Column("supplier_id", sa.String(), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)") + ), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["category_id"], + ["categories.id"], + ), + sa.ForeignKeyConstraint( + ["supplier_id"], + ["suppliers.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_products_barcode"), "products", ["barcode"], unique=True) + op.create_index(op.f("ix_products_id"), "products", ["id"], unique=False) + op.create_index(op.f("ix_products_name"), "products", ["name"], unique=False) + op.create_index(op.f("ix_products_sku"), "products", ["sku"], unique=True) + + # Create inventory_movements table + op.create_table( + "inventory_movements", + sa.Column("id", sa.String(), nullable=False), + sa.Column("product_id", sa.String(), nullable=False), + sa.Column("quantity", sa.Integer(), nullable=False), + sa.Column( + "type", + sa.Enum("STOCK_IN", "STOCK_OUT", "ADJUSTMENT", "RETURN", name="movementtype"), + nullable=False, + ), + sa.Column("reference", sa.String(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("unit_price", sa.Float(), nullable=True), + sa.Column("created_by", sa.String(), nullable=True), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.text("(CURRENT_TIMESTAMP)") + ), + sa.ForeignKeyConstraint( + ["created_by"], + ["users.id"], + ), + sa.ForeignKeyConstraint( + ["product_id"], + ["products.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_inventory_movements_id"), "inventory_movements", ["id"], unique=False) + + +def downgrade() -> None: + # Drop inventory_movements table + op.drop_index(op.f("ix_inventory_movements_id"), table_name="inventory_movements") + op.drop_table("inventory_movements") + + # Drop products table + op.drop_index(op.f("ix_products_sku"), table_name="products") + op.drop_index(op.f("ix_products_name"), table_name="products") + op.drop_index(op.f("ix_products_id"), table_name="products") + op.drop_index(op.f("ix_products_barcode"), table_name="products") + op.drop_table("products") + + # Drop suppliers table + op.drop_index(op.f("ix_suppliers_name"), table_name="suppliers") + op.drop_index(op.f("ix_suppliers_id"), table_name="suppliers") + op.drop_table("suppliers") + + # Drop categories table + op.drop_index(op.f("ix_categories_name"), table_name="categories") + op.drop_index(op.f("ix_categories_id"), table_name="categories") + op.drop_table("categories") + + # Drop users table + op.drop_index(op.f("ix_users_id"), table_name="users") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6e6892b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.ruff] +# Allow lines to be as long as 100 characters +line-length = 100 +# Assume Python 3.8 +target-version = "py38" + +[tool.ruff.lint] +# Enable pycodestyle (E), Pyflakes (F), isort (I), and more +select = ["E", "F", "I", "N", "W", "C90", "B", "UP"] +# Ignore specific rules: +# B008: Do not perform function call `Depends` in argument defaults +# E501: Line too long +# F401: Imported but unused +ignore = ["B008", "E501", "F401"] + +# Allow autofix for all enabled rules (when `--fix`) is provided +fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"] +unfixable = [] + +# Exclude a variety of commonly ignored directories +exclude = [ + ".git", + ".ruff_cache", + ".venv", + "venv", + "__pypackages__", + "migrations/versions", + "dist", + "node_modules", +] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[tool.ruff.lint.mccabe] +# Flag functions with a complexity higher than 15 +max-complexity = 15 + +[tool.ruff.lint.isort] +known-third-party = ["fastapi", "pydantic", "sqlalchemy", "alembic"] +section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dd7fbf9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +fastapi>=0.104.0 +uvicorn>=0.23.0 +sqlalchemy>=2.0.0 +alembic>=1.12.0 +pydantic>=2.4.0 +pydantic-settings>=2.0.0 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +python-multipart>=0.0.6 +email-validator>=2.0.0 +httpx>=0.25.0 +python-dotenv>=1.0.0 +ruff>=0.1.0 \ No newline at end of file