From 5935f302dc2f68767835db8363a08872cd81d037 Mon Sep 17 00:00:00 2001 From: Automated Action Date: Fri, 16 May 2025 08:53:15 +0000 Subject: [PATCH] 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 --- README.md | 117 ++++++- alembic.ini | 74 ++++ app/api/api_v1/api.py | 13 + app/api/api_v1/endpoints/categories.py | 109 ++++++ app/api/api_v1/endpoints/inventory.py | 162 +++++++++ app/api/api_v1/endpoints/login.py | 48 +++ app/api/api_v1/endpoints/products.py | 170 +++++++++ app/api/api_v1/endpoints/purchase_orders.py | 185 ++++++++++ app/api/api_v1/endpoints/reports.py | 341 +++++++++++++++++++ app/api/api_v1/endpoints/sales.py | 168 +++++++++ app/api/api_v1/endpoints/users.py | 116 +++++++ app/api/deps.py | 60 ++++ app/core/config.py | 42 +++ app/core/health.py | 39 +++ app/core/security/dependencies.py | 64 ++++ app/core/security/jwt.py | 46 +++ app/core/security/password.py | 18 + app/crud/base.py | 66 ++++ app/crud/crud_category.py | 15 + app/crud/crud_inventory.py | 84 +++++ app/crud/crud_product.py | 69 ++++ app/crud/crud_purchase_order.py | 129 +++++++ app/crud/crud_sale.py | 177 ++++++++++ app/crud/crud_user.py | 56 +++ app/db/base.py | 9 + app/db/base_class.py | 13 + app/db/session.py | 18 + app/models/category.py | 13 + app/models/inventory.py | 29 ++ app/models/product.py | 21 ++ app/models/purchase_order.py | 31 ++ app/models/sale.py | 31 ++ app/models/user.py | 17 + app/schemas/category.py | 31 ++ app/schemas/inventory.py | 41 +++ app/schemas/product.py | 45 +++ app/schemas/purchase_order.py | 66 ++++ app/schemas/sale.py | 66 ++++ app/schemas/token.py | 12 + app/schemas/user.py | 39 +++ main.py | 34 ++ migrations/env.py | 79 +++++ migrations/script.py.mako | 24 ++ migrations/versions/001_initial_migration.py | 168 +++++++++ migrations/versions/002_add_admin_user.py | 51 +++ migrations/versions/003_add_sample_data.py | 166 +++++++++ pyproject.toml | 14 + requirements.txt | 10 + run.py | 10 + 49 files changed, 3404 insertions(+), 2 deletions(-) create mode 100644 alembic.ini create mode 100644 app/api/api_v1/api.py create mode 100644 app/api/api_v1/endpoints/categories.py create mode 100644 app/api/api_v1/endpoints/inventory.py create mode 100644 app/api/api_v1/endpoints/login.py create mode 100644 app/api/api_v1/endpoints/products.py create mode 100644 app/api/api_v1/endpoints/purchase_orders.py create mode 100644 app/api/api_v1/endpoints/reports.py create mode 100644 app/api/api_v1/endpoints/sales.py create mode 100644 app/api/api_v1/endpoints/users.py create mode 100644 app/api/deps.py create mode 100644 app/core/config.py create mode 100644 app/core/health.py create mode 100644 app/core/security/dependencies.py create mode 100644 app/core/security/jwt.py create mode 100644 app/core/security/password.py create mode 100644 app/crud/base.py create mode 100644 app/crud/crud_category.py create mode 100644 app/crud/crud_inventory.py create mode 100644 app/crud/crud_product.py create mode 100644 app/crud/crud_purchase_order.py create mode 100644 app/crud/crud_sale.py create mode 100644 app/crud/crud_user.py create mode 100644 app/db/base.py create mode 100644 app/db/base_class.py create mode 100644 app/db/session.py create mode 100644 app/models/category.py create mode 100644 app/models/inventory.py create mode 100644 app/models/product.py create mode 100644 app/models/purchase_order.py create mode 100644 app/models/sale.py create mode 100644 app/models/user.py create mode 100644 app/schemas/category.py create mode 100644 app/schemas/inventory.py create mode 100644 app/schemas/product.py create mode 100644 app/schemas/purchase_order.py create mode 100644 app/schemas/sale.py create mode 100644 app/schemas/token.py create mode 100644 app/schemas/user.py create mode 100644 main.py create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/001_initial_migration.py create mode 100644 migrations/versions/002_add_admin_user.py create mode 100644 migrations/versions/003_add_sample_data.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 run.py diff --git a/README.md b/README.md index e8acfba..df7268a 100644 --- a/README.md +++ b/README.md @@ -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 \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..7322295 --- /dev/null +++ b/alembic.ini @@ -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 \ No newline at end of file diff --git a/app/api/api_v1/api.py b/app/api/api_v1/api.py new file mode 100644 index 0000000..3829161 --- /dev/null +++ b/app/api/api_v1/api.py @@ -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"]) \ No newline at end of file diff --git a/app/api/api_v1/endpoints/categories.py b/app/api/api_v1/endpoints/categories.py new file mode 100644 index 0000000..111aaba --- /dev/null +++ b/app/api/api_v1/endpoints/categories.py @@ -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 \ No newline at end of file diff --git a/app/api/api_v1/endpoints/inventory.py b/app/api/api_v1/endpoints/inventory.py new file mode 100644 index 0000000..2c51dcf --- /dev/null +++ b/app/api/api_v1/endpoints/inventory.py @@ -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 \ No newline at end of file diff --git a/app/api/api_v1/endpoints/login.py b/app/api/api_v1/endpoints/login.py new file mode 100644 index 0000000..5bc8e62 --- /dev/null +++ b/app/api/api_v1/endpoints/login.py @@ -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 \ No newline at end of file diff --git a/app/api/api_v1/endpoints/products.py b/app/api/api_v1/endpoints/products.py new file mode 100644 index 0000000..cdb11c5 --- /dev/null +++ b/app/api/api_v1/endpoints/products.py @@ -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) \ No newline at end of file diff --git a/app/api/api_v1/endpoints/purchase_orders.py b/app/api/api_v1/endpoints/purchase_orders.py new file mode 100644 index 0000000..7a8893f --- /dev/null +++ b/app/api/api_v1/endpoints/purchase_orders.py @@ -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 \ No newline at end of file diff --git a/app/api/api_v1/endpoints/reports.py b/app/api/api_v1/endpoints/reports.py new file mode 100644 index 0000000..a9156ca --- /dev/null +++ b/app/api/api_v1/endpoints/reports.py @@ -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() + } \ No newline at end of file diff --git a/app/api/api_v1/endpoints/sales.py b/app/api/api_v1/endpoints/sales.py new file mode 100644 index 0000000..b1a0ab3 --- /dev/null +++ b/app/api/api_v1/endpoints/sales.py @@ -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 \ No newline at end of file diff --git a/app/api/api_v1/endpoints/users.py b/app/api/api_v1/endpoints/users.py new file mode 100644 index 0000000..61dd0ff --- /dev/null +++ b/app/api/api_v1/endpoints/users.py @@ -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 \ No newline at end of file diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..650eb00 --- /dev/null +++ b/app/api/deps.py @@ -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 \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..5284923 --- /dev/null +++ b/app/core/config.py @@ -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() \ No newline at end of file diff --git a/app/core/health.py b/app/core/health.py new file mode 100644 index 0000000..99e66f6 --- /dev/null +++ b/app/core/health.py @@ -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 \ No newline at end of file diff --git a/app/core/security/dependencies.py b/app/core/security/dependencies.py new file mode 100644 index 0000000..8a80ea2 --- /dev/null +++ b/app/core/security/dependencies.py @@ -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 \ No newline at end of file diff --git a/app/core/security/jwt.py b/app/core/security/jwt.py new file mode 100644 index 0000000..5eaee22 --- /dev/null +++ b/app/core/security/jwt.py @@ -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 \ No newline at end of file diff --git a/app/core/security/password.py b/app/core/security/password.py new file mode 100644 index 0000000..965ecc1 --- /dev/null +++ b/app/core/security/password.py @@ -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) \ No newline at end of file diff --git a/app/crud/base.py b/app/crud/base.py new file mode 100644 index 0000000..12b61dd --- /dev/null +++ b/app/crud/base.py @@ -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 \ No newline at end of file diff --git a/app/crud/crud_category.py b/app/crud/crud_category.py new file mode 100644 index 0000000..89da4ba --- /dev/null +++ b/app/crud/crud_category.py @@ -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) \ No newline at end of file diff --git a/app/crud/crud_inventory.py b/app/crud/crud_inventory.py new file mode 100644 index 0000000..e33407f --- /dev/null +++ b/app/crud/crud_inventory.py @@ -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) \ No newline at end of file diff --git a/app/crud/crud_product.py b/app/crud/crud_product.py new file mode 100644 index 0000000..692130e --- /dev/null +++ b/app/crud/crud_product.py @@ -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) \ No newline at end of file diff --git a/app/crud/crud_purchase_order.py b/app/crud/crud_purchase_order.py new file mode 100644 index 0000000..e423075 --- /dev/null +++ b/app/crud/crud_purchase_order.py @@ -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) \ No newline at end of file diff --git a/app/crud/crud_sale.py b/app/crud/crud_sale.py new file mode 100644 index 0000000..bbee7df --- /dev/null +++ b/app/crud/crud_sale.py @@ -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) \ No newline at end of file diff --git a/app/crud/crud_user.py b/app/crud/crud_user.py new file mode 100644 index 0000000..d97f96c --- /dev/null +++ b/app/crud/crud_user.py @@ -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) \ No newline at end of file diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..64bd764 --- /dev/null +++ b/app/db/base.py @@ -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 \ No newline at end of file diff --git a/app/db/base_class.py b/app/db/base_class.py new file mode 100644 index 0000000..9b1a3da --- /dev/null +++ b/app/db/base_class.py @@ -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() \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..24b55ec --- /dev/null +++ b/app/db/session.py @@ -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() \ No newline at end of file diff --git a/app/models/category.py b/app/models/category.py new file mode 100644 index 0000000..8fd1d85 --- /dev/null +++ b/app/models/category.py @@ -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") \ No newline at end of file diff --git a/app/models/inventory.py b/app/models/inventory.py new file mode 100644 index 0000000..c0d45c1 --- /dev/null +++ b/app/models/inventory.py @@ -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") \ No newline at end of file diff --git a/app/models/product.py b/app/models/product.py new file mode 100644 index 0000000..79566ad --- /dev/null +++ b/app/models/product.py @@ -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") \ No newline at end of file diff --git a/app/models/purchase_order.py b/app/models/purchase_order.py new file mode 100644 index 0000000..2614ddf --- /dev/null +++ b/app/models/purchase_order.py @@ -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") \ No newline at end of file diff --git a/app/models/sale.py b/app/models/sale.py new file mode 100644 index 0000000..5654f7c --- /dev/null +++ b/app/models/sale.py @@ -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") \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..6c321eb --- /dev/null +++ b/app/models/user.py @@ -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") \ No newline at end of file diff --git a/app/schemas/category.py b/app/schemas/category.py new file mode 100644 index 0000000..c067af8 --- /dev/null +++ b/app/schemas/category.py @@ -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 \ No newline at end of file diff --git a/app/schemas/inventory.py b/app/schemas/inventory.py new file mode 100644 index 0000000..e2f0e57 --- /dev/null +++ b/app/schemas/inventory.py @@ -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 \ No newline at end of file diff --git a/app/schemas/product.py b/app/schemas/product.py new file mode 100644 index 0000000..2fe0af5 --- /dev/null +++ b/app/schemas/product.py @@ -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 \ No newline at end of file diff --git a/app/schemas/purchase_order.py b/app/schemas/purchase_order.py new file mode 100644 index 0000000..d57d39f --- /dev/null +++ b/app/schemas/purchase_order.py @@ -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) \ No newline at end of file diff --git a/app/schemas/sale.py b/app/schemas/sale.py new file mode 100644 index 0000000..d5070bf --- /dev/null +++ b/app/schemas/sale.py @@ -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) \ No newline at end of file diff --git a/app/schemas/token.py b/app/schemas/token.py new file mode 100644 index 0000000..9fefd18 --- /dev/null +++ b/app/schemas/token.py @@ -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 \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..c42a48f --- /dev/null +++ b/app/schemas/user.py @@ -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 \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..7587ca8 --- /dev/null +++ b/main.py @@ -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) \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..f3ccb7c --- /dev/null +++ b/migrations/env.py @@ -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() \ No newline at end of file diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1e4564e --- /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(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/migrations/versions/001_initial_migration.py b/migrations/versions/001_initial_migration.py new file mode 100644 index 0000000..8a66f64 --- /dev/null +++ b/migrations/versions/001_initial_migration.py @@ -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') \ No newline at end of file diff --git a/migrations/versions/002_add_admin_user.py b/migrations/versions/002_add_admin_user.py new file mode 100644 index 0000000..f4600e2 --- /dev/null +++ b/migrations/versions/002_add_admin_user.py @@ -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'") \ No newline at end of file diff --git a/migrations/versions/003_add_sample_data.py b/migrations/versions/003_add_sample_data.py new file mode 100644 index 0000000..548fdf3 --- /dev/null +++ b/migrations/versions/003_add_sample_data.py @@ -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)") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a6402e7 --- /dev/null +++ b/pyproject.toml @@ -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"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e7edcac --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/run.py b/run.py new file mode 100644 index 0000000..14e5ffc --- /dev/null +++ b/run.py @@ -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) \ No newline at end of file