From 439330125e3b317623d6f9e2a37a17979636bb42 Mon Sep 17 00:00:00 2001 From: Automated Action Date: Thu, 5 Jun 2025 16:58:14 +0000 Subject: [PATCH] Fix code linting issues - Fix unused imports in API endpoints - Add proper __all__ exports in model and schema modules - Add proper TYPE_CHECKING imports in models to prevent circular imports - Fix import order in migrations - Fix long lines in migration scripts - All ruff checks passing --- README.md | 134 +++++++++- alembic.ini | 103 ++++++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/deps.py | 62 +++++ app/api/v1/__init__.py | 0 app/api/v1/api.py | 40 +++ app/api/v1/endpoints/__init__.py | 0 app/api/v1/endpoints/analytics.py | 250 ++++++++++++++++++ app/api/v1/endpoints/auth.py | 49 ++++ app/api/v1/endpoints/categories.py | 88 ++++++ .../v1/endpoints/inventory_transactions.py | 135 ++++++++++ app/api/v1/endpoints/products.py | 178 +++++++++++++ app/api/v1/endpoints/suppliers.py | 88 ++++++ app/api/v1/endpoints/users.py | 120 +++++++++ app/core/config.py | 66 +++++ app/core/security.py | 42 +++ app/crud/__init__.py | 7 + app/crud/base.py | 84 ++++++ app/crud/crud_category.py | 44 +++ app/crud/crud_inventory_transaction.py | 105 ++++++++ app/crud/crud_product.py | 118 +++++++++ app/crud/crud_supplier.py | 48 ++++ app/crud/crud_user.py | 78 ++++++ app/db/base.py | 8 + app/db/base_class.py | 19 ++ app/db/session.py | 20 ++ app/models/__init__.py | 7 + app/models/category.py | 24 ++ app/models/inventory_transaction.py | 42 +++ app/models/product.py | 42 +++ app/models/supplier.py | 28 ++ app/models/user.py | 27 ++ app/schemas/__init__.py | 19 ++ app/schemas/category.py | 49 ++++ app/schemas/inventory_transaction.py | 58 ++++ app/schemas/product.py | 78 ++++++ app/schemas/supplier.py | 53 ++++ app/schemas/token.py | 18 ++ app/schemas/user.py | 60 +++++ main.py | 49 ++++ migrations/env.py | 80 ++++++ migrations/script.py.mako | 24 ++ .../aaa1bc2a6d3c_initial_migration.py | 114 ++++++++ pyproject.toml | 13 + requirements.txt | 13 + 46 files changed, 2682 insertions(+), 2 deletions(-) create mode 100644 alembic.ini create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/deps.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/api/v1/api.py create mode 100644 app/api/v1/endpoints/__init__.py create mode 100644 app/api/v1/endpoints/analytics.py create mode 100644 app/api/v1/endpoints/auth.py create mode 100644 app/api/v1/endpoints/categories.py create mode 100644 app/api/v1/endpoints/inventory_transactions.py create mode 100644 app/api/v1/endpoints/products.py create mode 100644 app/api/v1/endpoints/suppliers.py create mode 100644 app/api/v1/endpoints/users.py create mode 100644 app/core/config.py create mode 100644 app/core/security.py create mode 100644 app/crud/__init__.py create mode 100644 app/crud/base.py create mode 100644 app/crud/crud_category.py create mode 100644 app/crud/crud_inventory_transaction.py create mode 100644 app/crud/crud_product.py create mode 100644 app/crud/crud_supplier.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/__init__.py create mode 100644 app/models/category.py create mode 100644 app/models/inventory_transaction.py create mode 100644 app/models/product.py create mode 100644 app/models/supplier.py create mode 100644 app/models/user.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/category.py create mode 100644 app/schemas/inventory_transaction.py create mode 100644 app/schemas/product.py create mode 100644 app/schemas/supplier.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/aaa1bc2a6d3c_initial_migration.py create mode 100644 pyproject.toml create mode 100644 requirements.txt diff --git a/README.md b/README.md index e8acfba..02a0e0a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,133 @@ -# FastAPI Application +# Small Business Inventory Management System -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +## Overview +This project is a comprehensive inventory management system designed for small businesses. It provides a robust REST API for managing products, categories, suppliers, and inventory transactions, along with analytics for business insights. + +## Features +- **User Authentication**: Secure login and user management +- **Product Management**: CRUD operations for products with SKU, pricing, and stock levels +- **Category Management**: Organize products into categories +- **Supplier Management**: Track and manage your suppliers +- **Inventory Transactions**: Record purchases, sales, returns, and adjustments +- **Inventory Analytics**: Get insights into your inventory performance +- **Stock Alerts**: Identify low stock and out-of-stock products + +## Tech Stack +- **Framework**: FastAPI +- **Database**: SQLite +- **ORM**: SQLAlchemy +- **Authentication**: JWT (JSON Web Tokens) +- **Migrations**: Alembic + +## API Endpoints + +### Authentication +- `POST /api/v1/auth/login` - Login to get access token +- `POST /api/v1/auth/test-token` - Verify access token + +### Users +- `GET /api/v1/users/` - List all users (admin only) +- `POST /api/v1/users/` - Create a new user (admin only) +- `GET /api/v1/users/me` - Get current user info +- `PUT /api/v1/users/me` - Update current user info +- `GET /api/v1/users/{user_id}` - Get user by ID +- `PUT /api/v1/users/{user_id}` - Update user (admin only) + +### Categories +- `GET /api/v1/categories/` - List all categories +- `POST /api/v1/categories/` - Create a new category +- `GET /api/v1/categories/{id}` - Get category by ID +- `PUT /api/v1/categories/{id}` - Update category +- `DELETE /api/v1/categories/{id}` - Delete category (admin only) + +### Suppliers +- `GET /api/v1/suppliers/` - List all suppliers +- `POST /api/v1/suppliers/` - Create a new supplier +- `GET /api/v1/suppliers/{id}` - Get supplier by ID +- `PUT /api/v1/suppliers/{id}` - Update supplier +- `DELETE /api/v1/suppliers/{id}` - Delete supplier (admin only) + +### Products +- `GET /api/v1/products/` - List all products +- `POST /api/v1/products/` - Create a new product +- `GET /api/v1/products/{id}` - Get product by ID +- `PUT /api/v1/products/{id}` - Update product +- `DELETE /api/v1/products/{id}` - Delete product +- `GET /api/v1/products/by-category/{category_id}` - Get products by category +- `GET /api/v1/products/by-supplier/{supplier_id}` - Get products by supplier +- `GET /api/v1/products/low-stock` - Get low stock products +- `GET /api/v1/products/out-of-stock` - Get out of stock products + +### Inventory Transactions +- `POST /api/v1/inventory-transactions/` - Create a new transaction +- `GET /api/v1/inventory-transactions/` - List all transactions +- `GET /api/v1/inventory-transactions/{id}` - Get transaction by ID +- `GET /api/v1/inventory-transactions/by-product/{product_id}` - Get transactions by product +- `GET /api/v1/inventory-transactions/by-type/{transaction_type}` - Get transactions by type + +### Analytics +- `GET /api/v1/analytics/inventory-summary` - Get inventory summary +- `GET /api/v1/analytics/transaction-history` - Get transaction history +- `GET /api/v1/analytics/product-performance` - Get product performance +- `GET /api/v1/analytics/category-performance` - Get category performance + +## Installation and Setup + +### Prerequisites +- Python 3.8+ +- pip + +### Setting Up the Environment +1. Clone the repository: + ```bash + git clone + cd smallbusinessinventorymanagementsystem + ``` + +2. Install the required packages: + ```bash + pip install -r requirements.txt + ``` + +3. Create a `.env` file in the root directory (optional): + ``` + SECRET_KEY=your_secret_key_here + ACCESS_TOKEN_EXPIRE_MINUTES=43200 # 30 days + ``` + +### Database Setup +1. The application uses SQLite by default, stored at `/app/storage/db/db.sqlite`. + +2. Run the database migrations: + ```bash + alembic upgrade head + ``` + +### Running the Application +1. Start the FastAPI server: + ```bash + uvicorn main:app --reload + ``` + +2. Access the API documentation at: + - Swagger UI: http://localhost:8000/docs + - ReDoc: http://localhost:8000/redoc + +## Environment Variables +| Variable | Description | Default | +|----------|-------------|---------| +| SECRET_KEY | Secret key for JWT encoding | "CHANGE_ME_IN_PRODUCTION" | +| ACCESS_TOKEN_EXPIRE_MINUTES | Token expiration time in minutes | 10080 (7 days) | +| SQLALCHEMY_DATABASE_URL | Database connection URL | "sqlite:///app/storage/db/db.sqlite" | + +## Development +### Creating a Superuser +To create a superuser account, you can use the API or add a user directly to the database with the `is_superuser` flag set to `True`. + +### Running Tests +```bash +pytest +``` + +## License +This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..72f54e2 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,103 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# Specify the SQLite URL +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..2aac91b --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,62 @@ + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import jwt +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app import crud, models, schemas +from app.core import security +from app.core.config import settings +from app.db.session import get_db + +reusable_oauth2 = OAuth2PasswordBearer( + tokenUrl=f"{settings.API_V1_STR}/auth/login" +) + + +def get_current_user( + db: Session = Depends(get_db), token: str = Depends(reusable_oauth2) +) -> models.User: + """ + Dependency to get the current user from the token. + """ + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + token_data = schemas.TokenPayload(**payload) + except (jwt.JWTError, ValidationError): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials", + ) + user = crud.user.get(db, id=token_data.sub) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +def get_current_active_user( + current_user: models.User = Depends(get_current_user), +) -> models.User: + """ + Dependency to get the current active user. + """ + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + + +def get_current_active_superuser( + current_user: models.User = Depends(get_current_user), +) -> models.User: + """ + Dependency to get the current active superuser. + """ + if not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The user doesn't have enough privileges", + ) + return current_user \ No newline at end of file diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/api.py b/app/api/v1/api.py new file mode 100644 index 0000000..c41dd52 --- /dev/null +++ b/app/api/v1/api.py @@ -0,0 +1,40 @@ +from fastapi import APIRouter + +from app.api.v1.endpoints import ( + analytics, + auth, + categories, + inventory_transactions, + products, + suppliers, + users, +) + +api_router = APIRouter() + +# Auth routes +api_router.include_router(auth.router, prefix="/auth", tags=["authentication"]) + +# User routes +api_router.include_router(users.router, prefix="/users", tags=["users"]) + +# Category routes +api_router.include_router(categories.router, prefix="/categories", tags=["categories"]) + +# Supplier routes +api_router.include_router(suppliers.router, prefix="/suppliers", tags=["suppliers"]) + +# Product routes +api_router.include_router(products.router, prefix="/products", tags=["products"]) + +# Inventory transaction routes +api_router.include_router( + inventory_transactions.router, + prefix="/inventory-transactions", + tags=["inventory-transactions"], +) + +# Analytics routes +api_router.include_router( + analytics.router, prefix="/analytics", tags=["analytics"] +) \ No newline at end of file diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/analytics.py b/app/api/v1/endpoints/analytics.py new file mode 100644 index 0000000..5157a1b --- /dev/null +++ b/app/api/v1/endpoints/analytics.py @@ -0,0 +1,250 @@ +from datetime import datetime, timedelta +from typing import Any + +from fastapi import APIRouter, Depends +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app import crud, models +from app.api import deps +from app.models.inventory_transaction import TransactionType + +router = APIRouter() + + +@router.get("/inventory-summary") +def get_inventory_summary( + db: Session = Depends(deps.get_db), + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get summary of inventory status. + """ + # Get all products belonging to the user + products = crud.product.get_multi_by_owner(db, owner_id=current_user.id) + + # Calculate summary statistics + total_products = len(products) + total_inventory_value = sum(p.quantity * p.cost for p in products) + total_retail_value = sum(p.quantity * p.price for p in products) + potential_profit = total_retail_value - total_inventory_value + + low_stock_count = len([p for p in products if 0 < p.quantity <= p.reorder_level]) + out_of_stock_count = len([p for p in products if p.quantity == 0]) + in_stock_count = total_products - low_stock_count - out_of_stock_count + + return { + "total_products": total_products, + "total_inventory_value": round(total_inventory_value, 2), + "total_retail_value": round(total_retail_value, 2), + "potential_profit": round(potential_profit, 2), + "inventory_status": { + "in_stock": in_stock_count, + "low_stock": low_stock_count, + "out_of_stock": out_of_stock_count + } + } + + +@router.get("/transaction-history") +def get_transaction_history( + db: Session = Depends(deps.get_db), + days: int = 30, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get transaction history for the specified number of days. + """ + # Get all products belonging to the user + products = crud.product.get_multi_by_owner(db, owner_id=current_user.id) + product_ids = [p.id for p in products] + + # Get date range + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=days) + + # Get transactions for those products within the date range + transactions = ( + db.query( + models.InventoryTransaction.transaction_type, + func.count().label("count"), + func.sum(models.InventoryTransaction.quantity).label("total_quantity"), + ) + .filter( + models.InventoryTransaction.product_id.in_(product_ids), + models.InventoryTransaction.transaction_date >= start_date, + models.InventoryTransaction.transaction_date <= end_date, + ) + .group_by(models.InventoryTransaction.transaction_type) + .all() + ) + + # Format results + result = {} + for t_type in TransactionType: + result[t_type.value] = {"count": 0, "total_quantity": 0} + + for t_type, count, total_quantity in transactions: + result[t_type.value] = { + "count": count, + "total_quantity": total_quantity or 0 + } + + return { + "period_days": days, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "transactions_by_type": result + } + + +@router.get("/product-performance") +def get_product_performance( + db: Session = Depends(deps.get_db), + days: int = 30, + limit: int = 10, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get top-selling products for the specified number of days. + """ + # Get all products belonging to the user + products = crud.product.get_multi_by_owner(db, owner_id=current_user.id) + product_dict = {p.id: p for p in products} + + # Get date range + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=days) + + # Get sales transactions grouped by product + sales = ( + db.query( + models.InventoryTransaction.product_id, + func.sum(models.InventoryTransaction.quantity).label("quantity_sold"), + ) + .filter( + models.InventoryTransaction.product_id.in_(product_dict.keys()), + models.InventoryTransaction.transaction_type == TransactionType.SALE, + models.InventoryTransaction.transaction_date >= start_date, + models.InventoryTransaction.transaction_date <= end_date, + ) + .group_by(models.InventoryTransaction.product_id) + .order_by(func.sum(models.InventoryTransaction.quantity).desc()) + .limit(limit) + .all() + ) + + # Format results + top_selling = [] + for product_id, quantity_sold in sales: + product = product_dict.get(product_id) + if product: + top_selling.append({ + "id": product.id, + "name": product.name, + "sku": product.sku, + "quantity_sold": quantity_sold, + "revenue": round(quantity_sold * product.price, 2), + "profit": round(quantity_sold * (product.price - product.cost), 2), + }) + + return { + "period_days": days, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "top_selling_products": top_selling + } + + +@router.get("/category-performance") +def get_category_performance( + db: Session = Depends(deps.get_db), + days: int = 30, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get sales performance by category for the specified number of days. + """ + # Get all products belonging to the user + products = crud.product.get_multi_by_owner(db, owner_id=current_user.id) + + # Collect category IDs + category_ids = set() + for p in products: + if p.category_id: + category_ids.add(p.category_id) + + # Get categories + categories = crud.category.get_multi_by_ids(db, ids=list(category_ids)) + category_dict = {c.id: c for c in categories} + + # Get date range + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=days) + + # Get sales transactions for these products + sales = ( + db.query( + models.Product.category_id, + func.sum(models.InventoryTransaction.quantity).label("quantity_sold"), + ) + .join( + models.InventoryTransaction, + models.InventoryTransaction.product_id == models.Product.id + ) + .filter( + models.Product.owner_id == current_user.id, + models.InventoryTransaction.transaction_type == TransactionType.SALE, + models.InventoryTransaction.transaction_date >= start_date, + models.InventoryTransaction.transaction_date <= end_date, + ) + .group_by(models.Product.category_id) + .all() + ) + + # Format results + category_performance = [] + for category_id, quantity_sold in sales: + # Skip None category + if not category_id: + continue + + category = category_dict.get(category_id) + if category: + # Calculate revenue and profit for this category + revenue = 0 + profit = 0 + for p in products: + if p.category_id == category_id: + # Get sales for this specific product + product_sales = ( + db.query(func.sum(models.InventoryTransaction.quantity)) + .filter( + models.InventoryTransaction.product_id == p.id, + models.InventoryTransaction.transaction_type == TransactionType.SALE, + models.InventoryTransaction.transaction_date >= start_date, + models.InventoryTransaction.transaction_date <= end_date, + ) + .scalar() or 0 + ) + + revenue += product_sales * p.price + profit += product_sales * (p.price - p.cost) + + category_performance.append({ + "id": category.id, + "name": category.name, + "quantity_sold": quantity_sold, + "revenue": round(revenue, 2), + "profit": round(profit, 2), + }) + + # Sort by revenue + category_performance.sort(key=lambda x: x["revenue"], reverse=True) + + return { + "period_days": days, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "category_performance": category_performance + } \ No newline at end of file diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..539e49e --- /dev/null +++ b/app/api/v1/endpoints/auth.py @@ -0,0 +1,49 @@ +from datetime import timedelta +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app import crud, schemas +from app.api import deps +from app.core import security +from app.core.config import settings + +router = APIRouter() + + +@router.post("/login", response_model=schemas.Token) +def login_access_token( + db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends() +) -> Any: + """ + OAuth2 compatible token login, get an access token for future requests. + """ + user = crud.user.authenticate( + db, email=form_data.username, password=form_data.password + ) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + ) + elif not crud.user.is_active(user): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Inactive user" + ) + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return { + "access_token": security.create_access_token( + user.id, expires_delta=access_token_expires + ), + "token_type": "bearer", + } + + +@router.post("/test-token", response_model=schemas.User) +def test_token(current_user: schemas.User = Depends(deps.get_current_user)) -> Any: + """ + Test access token. + """ + return current_user \ No newline at end of file diff --git a/app/api/v1/endpoints/categories.py b/app/api/v1/endpoints/categories.py new file mode 100644 index 0000000..437b53a --- /dev/null +++ b/app/api/v1/endpoints/categories.py @@ -0,0 +1,88 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app import crud, models, schemas +from app.api import deps + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.Category]) +def read_categories( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve categories. + """ + categories = crud.category.get_multi(db, skip=skip, limit=limit) + return categories + + +@router.post("/", response_model=schemas.Category) +def create_category( + *, + db: Session = Depends(deps.get_db), + category_in: schemas.CategoryCreate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Create new category. + """ + category = crud.category.create(db, obj_in=category_in) + return category + + +@router.get("/{id}", response_model=schemas.Category) +def read_category( + *, + db: Session = Depends(deps.get_db), + id: str, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get category by ID. + """ + category = crud.category.get(db, id=id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + return category + + +@router.put("/{id}", response_model=schemas.Category) +def update_category( + *, + db: Session = Depends(deps.get_db), + id: str, + category_in: schemas.CategoryUpdate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Update a category. + """ + category = crud.category.get(db, id=id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + category = crud.category.update(db, db_obj=category, obj_in=category_in) + return category + + +@router.delete("/{id}", status_code=204, response_model=None) +def delete_category( + *, + db: Session = Depends(deps.get_db), + id: str, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: + """ + Delete a category. + """ + category = crud.category.get(db, id=id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + crud.category.remove(db, id=id) + return None \ No newline at end of file diff --git a/app/api/v1/endpoints/inventory_transactions.py b/app/api/v1/endpoints/inventory_transactions.py new file mode 100644 index 0000000..5bf1c98 --- /dev/null +++ b/app/api/v1/endpoints/inventory_transactions.py @@ -0,0 +1,135 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app import crud, models, schemas +from app.api import deps +from app.models.inventory_transaction import TransactionType + +router = APIRouter() + + +@router.post("/", response_model=schemas.InventoryTransaction) +def create_inventory_transaction( + *, + db: Session = Depends(deps.get_db), + transaction_in: schemas.InventoryTransactionCreate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Create new inventory transaction. + """ + # Verify product exists and belongs to the current user + product = crud.product.get(db, id=transaction_in.product_id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + if product.owner_id != current_user.id and not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + + try: + transaction = crud.inventory_transaction.create_with_product_update( + db, obj_in=transaction_in + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + return transaction + + +@router.get("/", response_model=List[schemas.InventoryTransaction]) +def read_inventory_transactions( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve inventory transactions. + """ + # Get all products belonging to the user + products = crud.product.get_multi_by_owner(db, owner_id=current_user.id) + product_ids = [p.id for p in products] + + # Get transactions for those products + transactions = db.query(models.InventoryTransaction).filter( + models.InventoryTransaction.product_id.in_(product_ids) + ).order_by( + models.InventoryTransaction.transaction_date.desc() + ).offset(skip).limit(limit).all() + + return transactions + + +@router.get("/by-product/{product_id}", response_model=List[schemas.InventoryTransaction]) +def read_inventory_transactions_by_product( + *, + db: Session = Depends(deps.get_db), + product_id: str, + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve inventory transactions by product. + """ + # Verify product exists and belongs to the current user + product = crud.product.get(db, id=product_id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + if product.owner_id != current_user.id and not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + + transactions = crud.inventory_transaction.get_by_product( + db, product_id=product_id, skip=skip, limit=limit + ) + return transactions + + +@router.get("/by-type/{transaction_type}", response_model=List[schemas.InventoryTransaction]) +def read_inventory_transactions_by_type( + *, + db: Session = Depends(deps.get_db), + transaction_type: TransactionType, + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve inventory transactions by transaction type. + """ + # Get all products belonging to the user + products = crud.product.get_multi_by_owner(db, owner_id=current_user.id) + product_ids = [p.id for p in products] + + # Get transactions for those products with the specified type + transactions = db.query(models.InventoryTransaction).filter( + models.InventoryTransaction.product_id.in_(product_ids), + models.InventoryTransaction.transaction_type == transaction_type + ).order_by( + models.InventoryTransaction.transaction_date.desc() + ).offset(skip).limit(limit).all() + + return transactions + + +@router.get("/{id}", response_model=schemas.InventoryTransaction) +def read_inventory_transaction( + *, + db: Session = Depends(deps.get_db), + id: str, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get inventory transaction by ID. + """ + transaction = crud.inventory_transaction.get(db, id=id) + if not transaction: + raise HTTPException(status_code=404, detail="Inventory transaction not found") + + # Verify product belongs to the current user + product = crud.product.get(db, id=transaction.product_id) + if product.owner_id != current_user.id and not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + + return transaction \ No newline at end of file diff --git a/app/api/v1/endpoints/products.py b/app/api/v1/endpoints/products.py new file mode 100644 index 0000000..7d7202b --- /dev/null +++ b/app/api/v1/endpoints/products.py @@ -0,0 +1,178 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app import crud, models, schemas +from app.api import deps + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.Product]) +def read_products( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve products. + """ + products = crud.product.get_multi_by_owner( + db, owner_id=current_user.id, skip=skip, limit=limit + ) + return products + + +@router.post("/", response_model=schemas.Product) +def create_product( + *, + db: Session = Depends(deps.get_db), + product_in: schemas.ProductCreate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Create new product. + """ + # Check if product with same SKU already exists + product = crud.product.get_by_sku(db, sku=product_in.sku) + if product: + raise HTTPException( + status_code=400, + detail="A product with this SKU already exists.", + ) + + product = crud.product.create_with_owner( + db, obj_in=product_in, owner_id=current_user.id + ) + return product + + +@router.get("/{id}", response_model=schemas.Product) +def read_product( + *, + db: Session = Depends(deps.get_db), + id: str, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get product by ID. + """ + product = crud.product.get(db, id=id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + if product.owner_id != current_user.id and not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + return product + + +@router.put("/{id}", response_model=schemas.Product) +def update_product( + *, + db: Session = Depends(deps.get_db), + id: str, + product_in: schemas.ProductUpdate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Update a product. + """ + product = crud.product.get(db, id=id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + if product.owner_id != current_user.id and not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + product = crud.product.update(db, db_obj=product, obj_in=product_in) + return product + + +@router.delete("/{id}", status_code=204, response_model=None) +def delete_product( + *, + db: Session = Depends(deps.get_db), + id: str, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Delete a product. + """ + product = crud.product.get(db, id=id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + if product.owner_id != current_user.id and not current_user.is_superuser: + raise HTTPException(status_code=403, detail="Not enough permissions") + crud.product.remove(db, id=id) + return None + + +@router.get("/by-category/{category_id}", response_model=List[schemas.Product]) +def read_products_by_category( + *, + db: Session = Depends(deps.get_db), + category_id: str, + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve products by category. + """ + products = crud.product.get_multi_by_category( + db, category_id=category_id, skip=skip, limit=limit + ) + # Filter by owner + return [p for p in products if p.owner_id == current_user.id] + + +@router.get("/by-supplier/{supplier_id}", response_model=List[schemas.Product]) +def read_products_by_supplier( + *, + db: Session = Depends(deps.get_db), + supplier_id: str, + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve products by supplier. + """ + products = crud.product.get_multi_by_supplier( + db, supplier_id=supplier_id, skip=skip, limit=limit + ) + # Filter by owner + return [p for p in products if p.owner_id == current_user.id] + + +@router.get("/low-stock", response_model=List[schemas.Product]) +def read_low_stock_products( + *, + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve products with stock levels below reorder level. + """ + products = crud.product.get_low_stock_products( + db, owner_id=current_user.id, skip=skip, limit=limit + ) + return products + + +@router.get("/out-of-stock", response_model=List[schemas.Product]) +def read_out_of_stock_products( + *, + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve products with zero stock. + """ + products = crud.product.get_out_of_stock_products( + db, owner_id=current_user.id, skip=skip, limit=limit + ) + return products \ No newline at end of file diff --git a/app/api/v1/endpoints/suppliers.py b/app/api/v1/endpoints/suppliers.py new file mode 100644 index 0000000..f21fe34 --- /dev/null +++ b/app/api/v1/endpoints/suppliers.py @@ -0,0 +1,88 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app import crud, models, schemas +from app.api import deps + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.Supplier]) +def read_suppliers( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve suppliers. + """ + suppliers = crud.supplier.get_multi(db, skip=skip, limit=limit) + return suppliers + + +@router.post("/", response_model=schemas.Supplier) +def create_supplier( + *, + db: Session = Depends(deps.get_db), + supplier_in: schemas.SupplierCreate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Create new supplier. + """ + supplier = crud.supplier.create(db, obj_in=supplier_in) + return supplier + + +@router.get("/{id}", response_model=schemas.Supplier) +def read_supplier( + *, + db: Session = Depends(deps.get_db), + id: str, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get supplier by ID. + """ + supplier = crud.supplier.get(db, id=id) + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + return supplier + + +@router.put("/{id}", response_model=schemas.Supplier) +def update_supplier( + *, + db: Session = Depends(deps.get_db), + id: str, + supplier_in: schemas.SupplierUpdate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Update a supplier. + """ + supplier = crud.supplier.get(db, id=id) + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + supplier = crud.supplier.update(db, db_obj=supplier, obj_in=supplier_in) + return supplier + + +@router.delete("/{id}", status_code=204, response_model=None) +def delete_supplier( + *, + db: Session = Depends(deps.get_db), + id: str, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: + """ + Delete a supplier. + """ + supplier = crud.supplier.get(db, id=id) + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + crud.supplier.remove(db, id=id) + return None \ No newline at end of file diff --git a/app/api/v1/endpoints/users.py b/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..6fa8a21 --- /dev/null +++ b/app/api/v1/endpoints/users.py @@ -0,0 +1,120 @@ +from typing import Any, List + +from fastapi import APIRouter, Body, Depends, HTTPException, status +from fastapi.encoders import jsonable_encoder +from pydantic import EmailStr +from sqlalchemy.orm import Session + +from app import crud, models, schemas +from app.api import deps + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.User]) +def read_users( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: + """ + Retrieve users. + """ + users = crud.user.get_multi(db, skip=skip, limit=limit) + return users + + +@router.post("/", response_model=schemas.User) +def create_user( + *, + db: Session = Depends(deps.get_db), + user_in: schemas.UserCreate, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: + """ + Create new user. + """ + user = crud.user.get_by_email(db, email=user_in.email) + if user: + raise HTTPException( + status_code=400, + detail="A user with this email already exists.", + ) + user = crud.user.create(db, obj_in=user_in) + return user + + +@router.put("/me", response_model=schemas.User) +def update_user_me( + *, + db: Session = Depends(deps.get_db), + password: str = Body(None), + full_name: str = Body(None), + email: EmailStr = Body(None), + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Update own user. + """ + current_user_data = jsonable_encoder(current_user) + user_in = schemas.UserUpdate(**current_user_data) + if password is not None: + user_in.password = password + if full_name is not None: + user_in.full_name = full_name + if email is not None: + user_in.email = email + user = crud.user.update(db, db_obj=current_user, obj_in=user_in) + return user + + +@router.get("/me", response_model=schemas.User) +def read_user_me( + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get current user. + """ + return current_user + + +@router.get("/{user_id}", response_model=schemas.User) +def read_user_by_id( + user_id: str, + current_user: models.User = Depends(deps.get_current_active_user), + db: Session = Depends(deps.get_db), +) -> Any: + """ + Get a specific user by id. + """ + user = crud.user.get(db, id=user_id) + if user == current_user: + return user + if not crud.user.is_superuser(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The user doesn't have enough privileges", + ) + return user + + +@router.put("/{user_id}", response_model=schemas.User) +def update_user( + *, + db: Session = Depends(deps.get_db), + user_id: str, + user_in: schemas.UserUpdate, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: + """ + Update a user. + """ + user = crud.user.get(db, id=user_id) + if not user: + raise HTTPException( + status_code=404, + detail="The user with this ID does not exist in the system", + ) + user = crud.user.update(db, db_obj=user, obj_in=user_in) + return user \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..c75dbd5 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,66 @@ +from pathlib import Path +from typing import List + +from pydantic import AnyHttpUrl, EmailStr, validator +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + PROJECT_NAME: str = "Small Business Inventory Management System" + DESCRIPTION: str = "API for managing inventory for small businesses" + VERSION: str = "0.1.0" + API_V1_STR: str = "/api/v1" + + # SECURITY + SECRET_KEY: str = "CHANGE_ME_IN_PRODUCTION" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days + + # Database setup + DB_DIR = Path("/app") / "storage" / "db" + DB_DIR.mkdir(parents=True, exist_ok=True) + SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite" + + # CORS + BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = [] + + @validator("BACKEND_CORS_ORIGINS", pre=True) + def assemble_cors_origins(cls, v: str | List[str]) -> List[str] | str: + if isinstance(v, str) and not v.startswith("["): + return [i.strip() for i in v.split(",")] + elif isinstance(v, (list, str)): + return v + raise ValueError(v) + + # Email + SMTP_TLS: bool = True + SMTP_PORT: int | None = None + SMTP_HOST: str | None = None + SMTP_USER: str | None = None + SMTP_PASSWORD: str | None = None + EMAILS_FROM_EMAIL: EmailStr | None = None + EMAILS_FROM_NAME: str | None = None + + @validator("EMAILS_FROM_NAME") + def get_project_name(cls, v: str | None, values: dict) -> str: + if not v: + return values["PROJECT_NAME"] + return v + + EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 + EMAIL_TEMPLATES_DIR: str = "/app/app/email-templates/build" + EMAILS_ENABLED: bool = False + + @validator("EMAILS_ENABLED", pre=True) + def get_emails_enabled(cls, v: bool, values: dict) -> bool: + return bool( + values.get("SMTP_HOST") + and values.get("SMTP_PORT") + and values.get("EMAILS_FROM_EMAIL") + ) + + class Config: + case_sensitive = True + env_file = ".env" + + +settings = Settings() \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..7e7f1b6 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,42 @@ +from datetime import datetime, timedelta +from typing import Any, Optional + +from jose import jwt +from passlib.context import CryptContext + +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +ALGORITHM = "HS256" + + +def create_access_token( + subject: str | Any, expires_delta: Optional[timedelta] = None +) -> str: + """ + Create a JWT token with the given subject and expiration time. + """ + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + Verify a password against a hash. + """ + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """ + Hash a password. + """ + return pwd_context.hash(password) \ No newline at end of file diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..0f6fde2 --- /dev/null +++ b/app/crud/__init__.py @@ -0,0 +1,7 @@ +from app.crud.crud_category import category +from app.crud.crud_inventory_transaction import inventory_transaction +from app.crud.crud_product import product +from app.crud.crud_supplier import supplier +from app.crud.crud_user import user + +__all__ = ["category", "inventory_transaction", "product", "supplier", "user"] \ No newline at end of file diff --git a/app/crud/base.py b/app/crud/base.py new file mode 100644 index 0000000..feea120 --- /dev/null +++ b/app/crud/base.py @@ -0,0 +1,84 @@ +from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union + +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.db.base_class import Base + +ModelType = TypeVar("ModelType", bound=Base) +CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) +UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) + + +class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): + """ + Base class for CRUD operations. + """ + def __init__(self, model: Type[ModelType]): + """ + CRUD object with default methods to Create, Read, Update, Delete (CRUD). + + **Parameters** + + * `model`: A SQLAlchemy model class + * `schema`: A Pydantic model (schema) class + """ + self.model = model + + def get(self, db: Session, id: Any) -> Optional[ModelType]: + """ + Get a record by ID. + """ + return db.query(self.model).filter(self.model.id == id).first() + + def get_multi( + self, db: Session, *, skip: int = 0, limit: int = 100 + ) -> List[ModelType]: + """ + Get multiple records. + """ + return db.query(self.model).offset(skip).limit(limit).all() + + def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType: + """ + Create a new record. + """ + obj_in_data = jsonable_encoder(obj_in) + db_obj = self.model(**obj_in_data) # type: ignore + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def update( + self, + db: Session, + *, + db_obj: ModelType, + obj_in: Union[UpdateSchemaType, Dict[str, Any]] + ) -> ModelType: + """ + Update a record. + """ + obj_data = jsonable_encoder(db_obj) + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.dict(exclude_unset=True) + for field in obj_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def remove(self, db: Session, *, id: Any) -> ModelType: + """ + Delete a record. + """ + obj = db.query(self.model).get(id) + db.delete(obj) + db.commit() + return obj \ 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..7464dad --- /dev/null +++ b/app/crud/crud_category.py @@ -0,0 +1,44 @@ +import uuid +from typing import List, Optional + +from sqlalchemy.orm import Session + +from app.crud.base import CRUDBase +from app.models.category import Category +from app.schemas.category import CategoryCreate, CategoryUpdate + + +class CRUDCategory(CRUDBase[Category, CategoryCreate, CategoryUpdate]): + """ + CRUD operations for Category model. + """ + def get_by_name(self, db: Session, *, name: str) -> Optional[Category]: + """ + Get a category by name. + """ + return db.query(Category).filter(Category.name == name).first() + + def create(self, db: Session, *, obj_in: CategoryCreate) -> Category: + """ + Create a new category. + """ + db_obj = Category( + id=str(uuid.uuid4()), + name=obj_in.name, + description=obj_in.description, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def get_multi_by_ids( + self, db: Session, *, ids: List[str], skip: int = 0, limit: int = 100 + ) -> List[Category]: + """ + Get multiple categories by IDs. + """ + return db.query(Category).filter(Category.id.in_(ids)).offset(skip).limit(limit).all() + + +category = CRUDCategory(Category) \ No newline at end of file diff --git a/app/crud/crud_inventory_transaction.py b/app/crud/crud_inventory_transaction.py new file mode 100644 index 0000000..83251da --- /dev/null +++ b/app/crud/crud_inventory_transaction.py @@ -0,0 +1,105 @@ +import uuid +from datetime import datetime +from typing import List + +from sqlalchemy.orm import Session + +from app.crud.base import CRUDBase +from app.models.inventory_transaction import InventoryTransaction, TransactionType +from app.models.product import Product +from app.schemas.inventory_transaction import ( + InventoryTransactionCreate, + InventoryTransactionUpdate, +) + + +class CRUDInventoryTransaction( + CRUDBase[InventoryTransaction, InventoryTransactionCreate, InventoryTransactionUpdate] +): + """ + CRUD operations for InventoryTransaction model. + """ + def create_with_product_update( + self, db: Session, *, obj_in: InventoryTransactionCreate + ) -> InventoryTransaction: + """ + Create a new inventory transaction and update product quantity. + """ + # Get the product + product = db.query(Product).filter(Product.id == obj_in.product_id).first() + if not product: + raise ValueError("Product not found") + + # Calculate the quantity change based on transaction type + if obj_in.transaction_type == TransactionType.PURCHASE: + product.quantity += obj_in.quantity + elif obj_in.transaction_type == TransactionType.SALE: + if product.quantity < obj_in.quantity: + raise ValueError("Not enough inventory for sale") + product.quantity -= obj_in.quantity + elif obj_in.transaction_type == TransactionType.ADJUSTMENT: + product.quantity += obj_in.quantity # Can be negative for reduction + elif obj_in.transaction_type == TransactionType.RETURN: + product.quantity += obj_in.quantity + elif obj_in.transaction_type == TransactionType.TRANSFER: + if product.quantity < obj_in.quantity: + raise ValueError("Not enough inventory for transfer") + product.quantity -= obj_in.quantity + + # Create the transaction + db_obj = InventoryTransaction( + id=str(uuid.uuid4()), + transaction_type=obj_in.transaction_type, + quantity=obj_in.quantity, + transaction_date=obj_in.transaction_date or datetime.utcnow(), + notes=obj_in.notes, + unit_price=obj_in.unit_price, + reference_number=obj_in.reference_number, + product_id=obj_in.product_id, + ) + + # Update the database + db.add(db_obj) + db.add(product) + db.commit() + db.refresh(db_obj) + + return db_obj + + def get_by_product( + self, db: Session, *, product_id: str, skip: int = 0, limit: int = 100 + ) -> List[InventoryTransaction]: + """ + Get transactions by product ID. + """ + return ( + db.query(InventoryTransaction) + .filter(InventoryTransaction.product_id == product_id) + .order_by(InventoryTransaction.transaction_date.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + def get_by_transaction_type( + self, + db: Session, + *, + transaction_type: TransactionType, + skip: int = 0, + limit: int = 100 + ) -> List[InventoryTransaction]: + """ + Get transactions by transaction type. + """ + return ( + db.query(InventoryTransaction) + .filter(InventoryTransaction.transaction_type == transaction_type) + .order_by(InventoryTransaction.transaction_date.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + +inventory_transaction = CRUDInventoryTransaction(InventoryTransaction) \ 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..457f1dc --- /dev/null +++ b/app/crud/crud_product.py @@ -0,0 +1,118 @@ +import uuid +from typing import List, Optional + +from sqlalchemy.orm import Session + +from app.crud.base import CRUDBase +from app.models.product import Product +from app.schemas.product import ProductCreate, ProductUpdate + + +class CRUDProduct(CRUDBase[Product, ProductCreate, ProductUpdate]): + """ + CRUD operations for Product model. + """ + def get_by_sku(self, db: Session, *, sku: str) -> Optional[Product]: + """ + Get a product by SKU. + """ + return db.query(Product).filter(Product.sku == sku).first() + + def create_with_owner( + self, db: Session, *, obj_in: ProductCreate, owner_id: str + ) -> Product: + """ + Create a new product with owner ID. + """ + db_obj = Product( + id=str(uuid.uuid4()), + name=obj_in.name, + description=obj_in.description, + sku=obj_in.sku, + price=obj_in.price, + cost=obj_in.cost, + quantity=obj_in.quantity, + reorder_level=obj_in.reorder_level, + category_id=obj_in.category_id, + supplier_id=obj_in.supplier_id, + owner_id=owner_id, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def get_multi_by_owner( + self, db: Session, *, owner_id: str, skip: int = 0, limit: int = 100 + ) -> List[Product]: + """ + Get multiple products by owner ID. + """ + return ( + db.query(Product) + .filter(Product.owner_id == owner_id) + .offset(skip) + .limit(limit) + .all() + ) + + def get_multi_by_category( + self, db: Session, *, category_id: str, skip: int = 0, limit: int = 100 + ) -> List[Product]: + """ + Get multiple products by category ID. + """ + return ( + db.query(Product) + .filter(Product.category_id == category_id) + .offset(skip) + .limit(limit) + .all() + ) + + def get_multi_by_supplier( + self, db: Session, *, supplier_id: str, skip: int = 0, limit: int = 100 + ) -> List[Product]: + """ + Get multiple products by supplier ID. + """ + return ( + db.query(Product) + .filter(Product.supplier_id == supplier_id) + .offset(skip) + .limit(limit) + .all() + ) + + def get_low_stock_products( + self, db: Session, *, owner_id: str, skip: int = 0, limit: int = 100 + ) -> List[Product]: + """ + Get products with stock levels below reorder level. + """ + return ( + db.query(Product) + .filter(Product.owner_id == owner_id) + .filter(Product.quantity <= Product.reorder_level) + .offset(skip) + .limit(limit) + .all() + ) + + def get_out_of_stock_products( + self, db: Session, *, owner_id: str, skip: int = 0, limit: int = 100 + ) -> List[Product]: + """ + Get products with zero stock. + """ + return ( + db.query(Product) + .filter(Product.owner_id == owner_id) + .filter(Product.quantity == 0) + .offset(skip) + .limit(limit) + .all() + ) + + +product = CRUDProduct(Product) \ No newline at end of file diff --git a/app/crud/crud_supplier.py b/app/crud/crud_supplier.py new file mode 100644 index 0000000..f1a7c85 --- /dev/null +++ b/app/crud/crud_supplier.py @@ -0,0 +1,48 @@ +import uuid +from typing import List, Optional + +from sqlalchemy.orm import Session + +from app.crud.base import CRUDBase +from app.models.supplier import Supplier +from app.schemas.supplier import SupplierCreate, SupplierUpdate + + +class CRUDSupplier(CRUDBase[Supplier, SupplierCreate, SupplierUpdate]): + """ + CRUD operations for Supplier model. + """ + def get_by_name(self, db: Session, *, name: str) -> Optional[Supplier]: + """ + Get a supplier by name. + """ + return db.query(Supplier).filter(Supplier.name == name).first() + + def create(self, db: Session, *, obj_in: SupplierCreate) -> Supplier: + """ + Create a new supplier. + """ + db_obj = Supplier( + id=str(uuid.uuid4()), + name=obj_in.name, + contact_name=obj_in.contact_name, + email=obj_in.email, + phone=obj_in.phone, + address=obj_in.address, + notes=obj_in.notes, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def get_multi_by_ids( + self, db: Session, *, ids: List[str], skip: int = 0, limit: int = 100 + ) -> List[Supplier]: + """ + Get multiple suppliers by IDs. + """ + return db.query(Supplier).filter(Supplier.id.in_(ids)).offset(skip).limit(limit).all() + + +supplier = CRUDSupplier(Supplier) \ 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..90d3472 --- /dev/null +++ b/app/crud/crud_user.py @@ -0,0 +1,78 @@ +import uuid +from typing import Any, Dict, Optional, Union + +from sqlalchemy.orm import Session + +from app.core.security import get_password_hash, verify_password +from app.crud.base import CRUDBase +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate + + +class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]): + """ + CRUD operations for User model. + """ + def get_by_email(self, db: Session, *, email: str) -> Optional[User]: + """ + Get a user by email. + """ + return db.query(User).filter(User.email == email).first() + + def create(self, db: Session, *, obj_in: UserCreate) -> User: + """ + Create a new user. + """ + db_obj = User( + id=str(uuid.uuid4()), + email=obj_in.email, + hashed_password=get_password_hash(obj_in.password), + full_name=obj_in.full_name, + is_superuser=obj_in.is_superuser, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def update( + self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]] + ) -> User: + """ + Update a user. + """ + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.dict(exclude_unset=True) + if update_data.get("password"): + hashed_password = get_password_hash(update_data["password"]) + del update_data["password"] + update_data["hashed_password"] = hashed_password + return super().update(db, db_obj=db_obj, obj_in=update_data) + + def authenticate(self, db: Session, *, email: str, password: str) -> Optional[User]: + """ + Authenticate a user. + """ + user = self.get_by_email(db, email=email) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user + + def is_active(self, user: User) -> bool: + """ + Check if a user is active. + """ + return user.is_active + + def is_superuser(self, user: User) -> bool: + """ + Check if a user is a superuser. + """ + return user.is_superuser + + +user = CRUDUser(User) \ No newline at end of file diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..3f03c79 --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,8 @@ +# Import all the models, so that Base has them before being +# imported by Alembic +from app.db.base_class import Base # noqa +from app.models.user import User # noqa +from app.models.category import Category # noqa +from app.models.supplier import Supplier # noqa +from app.models.product import Product # noqa +from app.models.inventory_transaction import InventoryTransaction # noqa \ 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..d10ae3a --- /dev/null +++ b/app/db/base_class.py @@ -0,0 +1,19 @@ +from typing import Any + +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """ + Base class for all database models. + """ + + id: Any + + @declared_attr + def __tablename__(cls) -> str: + """ + Generate __tablename__ automatically from the class name. + """ + return cls.__name__.lower() \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..1e66067 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,20 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.core.config import settings + +engine = create_engine( + settings.SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +def get_db(): + """ + Dependency function to get a database session. + """ + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..350ddef --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,7 @@ +from app.models.category import Category +from app.models.inventory_transaction import InventoryTransaction, TransactionType +from app.models.product import Product +from app.models.supplier import Supplier +from app.models.user import User + +__all__ = ["Category", "InventoryTransaction", "TransactionType", "Product", "Supplier", "User"] diff --git a/app/models/category.py b/app/models/category.py new file mode 100644 index 0000000..f2ecc47 --- /dev/null +++ b/app/models/category.py @@ -0,0 +1,24 @@ +from datetime import datetime +from typing import TYPE_CHECKING, List + +from sqlalchemy import Column, DateTime, String +from sqlalchemy.orm import Mapped, relationship + +from app.db.base_class import Base + +if TYPE_CHECKING: + from app.models.product import Product + + +class Category(Base): + """ + Database model for product categories. + """ + id = Column(String, primary_key=True, index=True) + name = Column(String, index=True, nullable=False) + description = Column(String, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + products: Mapped[List["Product"]] = relationship("Product", back_populates="category") \ No newline at end of file diff --git a/app/models/inventory_transaction.py b/app/models/inventory_transaction.py new file mode 100644 index 0000000..5a85648 --- /dev/null +++ b/app/models/inventory_transaction.py @@ -0,0 +1,42 @@ +from datetime import datetime +from enum import Enum as PyEnum +from typing import TYPE_CHECKING + +from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, relationship + +from app.db.base_class import Base + +if TYPE_CHECKING: + from app.models.product import Product + + +class TransactionType(str, PyEnum): + """ + Enum for inventory transaction types. + """ + PURCHASE = "purchase" + SALE = "sale" + ADJUSTMENT = "adjustment" + RETURN = "return" + TRANSFER = "transfer" + + +class InventoryTransaction(Base): + """ + Database model for inventory transactions. + """ + id = Column(String, primary_key=True, index=True) + transaction_type = Column(Enum(TransactionType), nullable=False) + quantity = Column(Integer, nullable=False) + transaction_date = Column(DateTime, default=datetime.utcnow) + notes = Column(String, nullable=True) + unit_price = Column(Float, nullable=True) + reference_number = Column(String, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + # Foreign keys + product_id = Column(String, ForeignKey("product.id"), nullable=False) + + # Relationships + product: Mapped["Product"] = relationship("Product", back_populates="inventory_transactions") \ No newline at end of file diff --git a/app/models/product.py b/app/models/product.py new file mode 100644 index 0000000..430ca89 --- /dev/null +++ b/app/models/product.py @@ -0,0 +1,42 @@ +from datetime import datetime +from typing import TYPE_CHECKING, List, Optional + +from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, relationship + +from app.db.base_class import Base + +if TYPE_CHECKING: + from app.models.category import Category + from app.models.inventory_transaction import InventoryTransaction + from app.models.supplier import Supplier + from app.models.user import User + + +class Product(Base): + """ + Database model for products. + """ + id = Column(String, primary_key=True, index=True) + name = Column(String, index=True, nullable=False) + description = Column(String, nullable=True) + sku = Column(String, unique=True, index=True, nullable=False) + price = Column(Float, nullable=False) + cost = Column(Float, nullable=False) + quantity = Column(Integer, default=0) + reorder_level = Column(Integer, default=10) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Foreign keys + category_id = Column(String, ForeignKey("category.id"), nullable=True) + supplier_id = Column(String, ForeignKey("supplier.id"), nullable=True) + owner_id = Column(String, ForeignKey("user.id"), nullable=False) + + # Relationships + category: Mapped[Optional["Category"]] = relationship("Category", back_populates="products") + supplier: Mapped[Optional["Supplier"]] = relationship("Supplier", back_populates="products") + owner: Mapped["User"] = relationship("User", back_populates="products") + inventory_transactions: Mapped[List["InventoryTransaction"]] = relationship( + "InventoryTransaction", back_populates="product" + ) \ No newline at end of file diff --git a/app/models/supplier.py b/app/models/supplier.py new file mode 100644 index 0000000..fc5a203 --- /dev/null +++ b/app/models/supplier.py @@ -0,0 +1,28 @@ +from datetime import datetime +from typing import TYPE_CHECKING, List + +from sqlalchemy import Column, DateTime, String +from sqlalchemy.orm import Mapped, relationship + +from app.db.base_class import Base + +if TYPE_CHECKING: + from app.models.product import Product + + +class Supplier(Base): + """ + Database model for suppliers. + """ + id = Column(String, primary_key=True, index=True) + name = Column(String, index=True, nullable=False) + contact_name = Column(String, nullable=True) + email = Column(String, nullable=True) + phone = Column(String, nullable=True) + address = Column(String, nullable=True) + notes = Column(String, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + products: Mapped[List["Product"]] = relationship("Product", back_populates="supplier") \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..c16f9ff --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,27 @@ +from datetime import datetime +from typing import TYPE_CHECKING, List + +from sqlalchemy import Boolean, Column, DateTime, String +from sqlalchemy.orm import Mapped, relationship + +from app.db.base_class import Base + +if TYPE_CHECKING: + from app.models.product import Product + + +class User(Base): + """ + Database model for users. + """ + id = Column(String, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + full_name = Column(String, nullable=True) + hashed_password = Column(String, nullable=False) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + products: Mapped[List["Product"]] = relationship("Product", back_populates="owner") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..8d216e9 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,19 @@ +from app.schemas.category import Category, CategoryCreate, CategoryUpdate +from app.schemas.inventory_transaction import ( + InventoryTransaction, + InventoryTransactionCreate, + InventoryTransactionUpdate, +) +from app.schemas.product import Product, ProductCreate, ProductUpdate +from app.schemas.supplier import Supplier, SupplierCreate, SupplierUpdate +from app.schemas.token import Token, TokenPayload +from app.schemas.user import User, UserCreate, UserUpdate + +__all__ = [ + "Category", "CategoryCreate", "CategoryUpdate", + "InventoryTransaction", "InventoryTransactionCreate", "InventoryTransactionUpdate", + "Product", "ProductCreate", "ProductUpdate", + "Supplier", "SupplierCreate", "SupplierUpdate", + "Token", "TokenPayload", + "User", "UserCreate", "UserUpdate", +] diff --git a/app/schemas/category.py b/app/schemas/category.py new file mode 100644 index 0000000..b9033db --- /dev/null +++ b/app/schemas/category.py @@ -0,0 +1,49 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +# Shared properties +class CategoryBase(BaseModel): + """ + Base schema for category data. + """ + name: Optional[str] = None + description: Optional[str] = None + + +# Properties to receive via API on creation +class CategoryCreate(CategoryBase): + """ + Schema for creating new categories. + """ + name: str + + +# Properties to receive via API on update +class CategoryUpdate(CategoryBase): + """ + Schema for updating category data. + """ + pass + + +class CategoryInDBBase(CategoryBase): + """ + Base schema for category data from the database. + """ + id: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# Additional properties to return via API +class Category(CategoryInDBBase): + """ + Schema for category data to return via API. + """ + pass \ No newline at end of file diff --git a/app/schemas/inventory_transaction.py b/app/schemas/inventory_transaction.py new file mode 100644 index 0000000..ec18cee --- /dev/null +++ b/app/schemas/inventory_transaction.py @@ -0,0 +1,58 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + +from app.models.inventory_transaction import TransactionType + + +# Shared properties +class InventoryTransactionBase(BaseModel): + """ + Base schema for inventory transaction data. + """ + transaction_type: Optional[TransactionType] = None + quantity: Optional[int] = None + transaction_date: Optional[datetime] = None + notes: Optional[str] = None + unit_price: Optional[float] = None + reference_number: Optional[str] = None + product_id: Optional[str] = None + + +# Properties to receive via API on creation +class InventoryTransactionCreate(InventoryTransactionBase): + """ + Schema for creating new inventory transactions. + """ + transaction_type: TransactionType + quantity: int + product_id: str + transaction_date: datetime = Field(default_factory=datetime.utcnow) + + +# Properties to receive via API on update +class InventoryTransactionUpdate(InventoryTransactionBase): + """ + Schema for updating inventory transaction data. + """ + pass + + +class InventoryTransactionInDBBase(InventoryTransactionBase): + """ + Base schema for inventory transaction data from the database. + """ + id: str + created_at: datetime + + class Config: + from_attributes = True + + +# Additional properties to return via API +class InventoryTransaction(InventoryTransactionInDBBase): + """ + Schema for inventory transaction data to return via API. + """ + pass \ No newline at end of file diff --git a/app/schemas/product.py b/app/schemas/product.py new file mode 100644 index 0000000..bd5be0e --- /dev/null +++ b/app/schemas/product.py @@ -0,0 +1,78 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel + + +# Shared properties +class ProductBase(BaseModel): + """ + Base schema for product data. + """ + name: Optional[str] = None + description: Optional[str] = None + sku: Optional[str] = None + price: Optional[float] = None + cost: Optional[float] = None + quantity: Optional[int] = None + reorder_level: Optional[int] = None + category_id: Optional[str] = None + supplier_id: Optional[str] = None + + +# Properties to receive via API on creation +class ProductCreate(ProductBase): + """ + Schema for creating new products. + """ + name: str + sku: str + price: float + cost: float + quantity: int = 0 + reorder_level: int = 10 + + +# Properties to receive via API on update +class ProductUpdate(ProductBase): + """ + Schema for updating product data. + """ + pass + + +class ProductInDBBase(ProductBase): + """ + Base schema for product data from the database. + """ + id: str + owner_id: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# Additional properties to return via API +class Product(ProductInDBBase): + """ + Schema for product data to return via API. + """ + pass + + +# Additional properties for inventory status +class ProductInventoryStatus(BaseModel): + """ + Schema for product inventory status. + """ + id: str + name: str + sku: str + quantity: int + reorder_level: int + status: str # "In Stock", "Low Stock", "Out of Stock" + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/schemas/supplier.py b/app/schemas/supplier.py new file mode 100644 index 0000000..a000444 --- /dev/null +++ b/app/schemas/supplier.py @@ -0,0 +1,53 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr + + +# Shared properties +class SupplierBase(BaseModel): + """ + Base schema for supplier data. + """ + name: Optional[str] = None + contact_name: Optional[str] = None + email: Optional[EmailStr] = None + phone: Optional[str] = None + address: Optional[str] = None + notes: Optional[str] = None + + +# Properties to receive via API on creation +class SupplierCreate(SupplierBase): + """ + Schema for creating new suppliers. + """ + name: str + + +# Properties to receive via API on update +class SupplierUpdate(SupplierBase): + """ + Schema for updating supplier data. + """ + pass + + +class SupplierInDBBase(SupplierBase): + """ + Base schema for supplier data from the database. + """ + id: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# Additional properties to return via API +class Supplier(SupplierInDBBase): + """ + Schema for supplier data to return via API. + """ + pass \ No newline at end of file diff --git a/app/schemas/token.py b/app/schemas/token.py new file mode 100644 index 0000000..4a6c09e --- /dev/null +++ b/app/schemas/token.py @@ -0,0 +1,18 @@ +from typing import Optional + +from pydantic import BaseModel + + +class Token(BaseModel): + """ + Schema for authentication token response. + """ + access_token: str + token_type: str + + +class TokenPayload(BaseModel): + """ + Schema for JWT token payload. + """ + sub: Optional[str] = None \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..e61a727 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,60 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr + + +# Shared properties +class UserBase(BaseModel): + """ + Base schema for user data. + """ + email: Optional[EmailStr] = None + full_name: Optional[str] = None + is_active: Optional[bool] = True + is_superuser: bool = False + + +# Properties to receive via API on creation +class UserCreate(UserBase): + """ + Schema for creating new users. + """ + email: EmailStr + password: str + + +# Properties to receive via API on update +class UserUpdate(UserBase): + """ + Schema for updating user data. + """ + password: Optional[str] = None + + +class UserInDBBase(UserBase): + """ + Base schema for user data from the database. + """ + id: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# Additional properties to return via API +class User(UserInDBBase): + """ + Schema for user data to return via API. + """ + pass + + +# Additional properties stored in DB +class UserInDB(UserInDBBase): + """ + Schema for user data stored in the database. + """ + hashed_password: str \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..232e4c1 --- /dev/null +++ b/main.py @@ -0,0 +1,49 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1.api import api_router +from app.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, + openapi_url="/openapi.json", + version=settings.VERSION, + description=settings.DESCRIPTION, +) + +# Set up CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include API router +app.include_router(api_router, prefix=settings.API_V1_STR) + + +@app.get("/") +async def root(): + """ + Root endpoint that returns basic application information. + """ + return { + "title": settings.PROJECT_NAME, + "description": settings.DESCRIPTION, + "version": settings.VERSION, + "docs_url": "/docs", + "health_check": "/health", + } + + +@app.get("/health", status_code=200) +async def health_check(): + """ + Health check endpoint to verify the service is up and running. + """ + return { + "status": "healthy", + "version": settings.VERSION, + } \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..656e4b2 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,80 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import settings +from app.db.base import Base # noqa - imported for alembic autogenerate + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = settings.SQLALCHEMY_DATABASE_URL + context.configure( + url=url, + target_metadata=Base.metadata, + literal_binds=True, + compare_type=True, + render_as_batch=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + configuration = config.get_section(config.config_ini_section) + configuration["sqlalchemy.url"] = settings.SQLALCHEMY_DATABASE_URL + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + is_sqlite = connection.dialect.name == "sqlite" + context.configure( + connection=connection, + target_metadata=Base.metadata, + compare_type=True, + render_as_batch=is_sqlite, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ 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/aaa1bc2a6d3c_initial_migration.py b/migrations/versions/aaa1bc2a6d3c_initial_migration.py new file mode 100644 index 0000000..63c574a --- /dev/null +++ b/migrations/versions/aaa1bc2a6d3c_initial_migration.py @@ -0,0 +1,114 @@ +"""Initial migration + +Revision ID: aaa1bc2a6d3c +Revises: +Create Date: 2023-11-16 12:00:00.000000 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'aaa1bc2a6d3c' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # Create user table + op.create_table( + 'user', + sa.Column('id', sa.String(), primary_key=True, index=True), + sa.Column('email', sa.String(), nullable=False, unique=True, index=True), + sa.Column('full_name', sa.String(), nullable=True), + sa.Column('hashed_password', sa.String(), nullable=False), + sa.Column('is_active', sa.Boolean(), default=True), + sa.Column('is_superuser', sa.Boolean(), default=False), + sa.Column('created_at', sa.DateTime(), default=sa.func.current_timestamp()), + sa.Column( + 'updated_at', + sa.DateTime(), + default=sa.func.current_timestamp(), + onupdate=sa.func.current_timestamp() + ), + ) + + # Create category table + op.create_table( + 'category', + sa.Column('id', sa.String(), primary_key=True, index=True), + sa.Column('name', sa.String(), nullable=False, index=True), + sa.Column('description', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), default=sa.func.current_timestamp()), + sa.Column( + 'updated_at', + sa.DateTime(), + default=sa.func.current_timestamp(), + onupdate=sa.func.current_timestamp() + ), + ) + + # Create supplier table + op.create_table( + 'supplier', + sa.Column('id', sa.String(), primary_key=True, index=True), + sa.Column('name', sa.String(), nullable=False, index=True), + sa.Column('contact_name', sa.String(), nullable=True), + sa.Column('email', sa.String(), nullable=True), + sa.Column('phone', sa.String(), nullable=True), + sa.Column('address', sa.String(), nullable=True), + sa.Column('notes', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(), default=sa.func.current_timestamp()), + sa.Column( + 'updated_at', + sa.DateTime(), + default=sa.func.current_timestamp(), + onupdate=sa.func.current_timestamp() + ), + ) + + # Create product table + op.create_table( + 'product', + sa.Column('id', sa.String(), primary_key=True, index=True), + sa.Column('name', sa.String(), nullable=False, index=True), + sa.Column('description', sa.String(), nullable=True), + sa.Column('sku', sa.String(), nullable=False, unique=True, index=True), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('cost', sa.Float(), nullable=False), + sa.Column('quantity', sa.Integer(), default=0), + sa.Column('reorder_level', sa.Integer(), default=10), + sa.Column('category_id', sa.String(), sa.ForeignKey('category.id'), nullable=True), + sa.Column('supplier_id', sa.String(), sa.ForeignKey('supplier.id'), nullable=True), + sa.Column('owner_id', sa.String(), sa.ForeignKey('user.id'), nullable=False), + sa.Column('created_at', sa.DateTime(), default=sa.func.current_timestamp()), + sa.Column( + 'updated_at', + sa.DateTime(), + default=sa.func.current_timestamp(), + onupdate=sa.func.current_timestamp() + ), + ) + + # Create inventory_transaction table + op.create_table( + 'inventorytransaction', + sa.Column('id', sa.String(), primary_key=True, index=True), + sa.Column('transaction_type', sa.String(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.Column('transaction_date', sa.DateTime(), default=sa.func.current_timestamp()), + sa.Column('notes', sa.String(), nullable=True), + sa.Column('unit_price', sa.Float(), nullable=True), + sa.Column('reference_number', sa.String(), nullable=True), + sa.Column('product_id', sa.String(), sa.ForeignKey('product.id'), nullable=False), + sa.Column('created_at', sa.DateTime(), default=sa.func.current_timestamp()), + ) + + +def downgrade(): + op.drop_table('inventorytransaction') + op.drop_table('product') + op.drop_table('supplier') + op.drop_table('category') + op.drop_table('user') \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5b3505c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +[tool.ruff] +line-length = 100 +target-version = "py38" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort +] + +[tool.ruff.lint.isort] +known-third-party = ["fastapi", "pydantic", "starlette", "sqlalchemy"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3dc3ecc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +fastapi>=0.103.1 +uvicorn>=0.23.2 +sqlalchemy>=2.0.20 +pydantic>=2.4.2 +pydantic-settings>=2.0.3 +alembic>=1.12.0 +python-jose>=3.3.0 +passlib>=1.7.4 +python-multipart>=0.0.6 +ruff>=0.0.290 +bcrypt>=4.0.1 +email-validator>=2.0.0 +python-dotenv>=1.0.0 \ No newline at end of file