From dc3702b55c3b0462ccc711f8d2eaed58e64ad21b Mon Sep 17 00:00:00 2001 From: Automated Action Date: Mon, 2 Jun 2025 11:37:11 +0000 Subject: [PATCH] Update code via agent code generation --- README.md | 142 +++++++++++++- alembic.ini | 85 +++++++++ app/__init__.py | 1 + app/api/__init__.py | 1 + app/api/api.py | 22 +++ app/api/endpoints/__init__.py | 1 + app/api/endpoints/auth.py | 79 ++++++++ app/api/endpoints/health.py | 33 ++++ app/api/endpoints/inventory.py | 172 +++++++++++++++++ app/api/endpoints/orders.py | 215 ++++++++++++++++++++++ app/api/endpoints/products.py | 109 +++++++++++ app/api/endpoints/shipments.py | 190 +++++++++++++++++++ app/api/endpoints/users.py | 156 ++++++++++++++++ app/api/endpoints/warehouses.py | 109 +++++++++++ app/core/__init__.py | 1 + app/core/auth.py | 70 +++++++ app/core/config.py | 37 ++++ app/core/deps.py | 50 +++++ app/core/security.py | 32 ++++ app/db/__init__.py | 1 + app/db/base.py | 9 + app/db/session.py | 21 +++ app/models/__init__.py | 1 + app/models/inventory.py | 25 +++ app/models/order.py | 48 +++++ app/models/product.py | 22 +++ app/models/shipment.py | 54 ++++++ app/models/user.py | 20 ++ app/models/warehouse.py | 23 +++ app/schemas/__init__.py | 34 ++++ app/schemas/inventory.py | 48 +++++ app/schemas/order.py | 74 ++++++++ app/schemas/product.py | 43 +++++ app/schemas/shipment.py | 89 +++++++++ app/schemas/token.py | 13 ++ app/schemas/user.py | 41 +++++ app/schemas/warehouse.py | 48 +++++ app/services/__init__.py | 1 + app/services/inventory.py | 138 ++++++++++++++ app/services/order.py | 142 ++++++++++++++ app/services/product.py | 61 ++++++ app/services/shipment.py | 174 +++++++++++++++++ app/services/user.py | 84 +++++++++ app/services/warehouse.py | 62 +++++++ main.py | 56 ++++++ migrations/README | 26 +++ migrations/__init__.py | 1 + migrations/env.py | 84 +++++++++ migrations/script.py.mako | 24 +++ migrations/versions/001_initial_schema.py | 177 ++++++++++++++++++ requirements.txt | 11 ++ 51 files changed, 3158 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/api.py create mode 100644 app/api/endpoints/__init__.py create mode 100644 app/api/endpoints/auth.py create mode 100644 app/api/endpoints/health.py create mode 100644 app/api/endpoints/inventory.py create mode 100644 app/api/endpoints/orders.py create mode 100644 app/api/endpoints/products.py create mode 100644 app/api/endpoints/shipments.py create mode 100644 app/api/endpoints/users.py create mode 100644 app/api/endpoints/warehouses.py create mode 100644 app/core/__init__.py create mode 100644 app/core/auth.py create mode 100644 app/core/config.py create mode 100644 app/core/deps.py create mode 100644 app/core/security.py create mode 100644 app/db/__init__.py create mode 100644 app/db/base.py create mode 100644 app/db/session.py create mode 100644 app/models/__init__.py create mode 100644 app/models/inventory.py create mode 100644 app/models/order.py create mode 100644 app/models/product.py create mode 100644 app/models/shipment.py create mode 100644 app/models/user.py create mode 100644 app/models/warehouse.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/inventory.py create mode 100644 app/schemas/order.py create mode 100644 app/schemas/product.py create mode 100644 app/schemas/shipment.py create mode 100644 app/schemas/token.py create mode 100644 app/schemas/user.py create mode 100644 app/schemas/warehouse.py create mode 100644 app/services/__init__.py create mode 100644 app/services/inventory.py create mode 100644 app/services/order.py create mode 100644 app/services/product.py create mode 100644 app/services/shipment.py create mode 100644 app/services/user.py create mode 100644 app/services/warehouse.py create mode 100644 main.py create mode 100644 migrations/README create mode 100644 migrations/__init__.py create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/001_initial_schema.py create mode 100644 requirements.txt diff --git a/README.md b/README.md index e8acfba..331a726 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,141 @@ -# FastAPI Application +# Logistics Management System API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A comprehensive API for managing logistics operations including warehouses, inventory, orders, shipments, and more. + +## Features + +- **User Management**: Authentication, authorization, and user management +- **Product Management**: Create, update, and track products +- **Warehouse Management**: Manage multiple warehouses and their details +- **Inventory Management**: Track inventory levels across warehouses +- **Order Management**: Process customer orders from creation to delivery +- **Shipment Management**: Track shipments between warehouses or to customers +- **Health Check**: Monitor system health and database connectivity + +## Tech Stack + +- **Framework**: FastAPI +- **Database**: SQLite with SQLAlchemy ORM +- **Authentication**: JWT token-based authentication +- **Migration**: Alembic for database migrations +- **Validation**: Pydantic models for request/response validation + +## Project Structure + +``` +├── app # Application source code +│ ├── api # API endpoint definitions +│ │ └── endpoints # API endpoints for each domain +│ ├── core # Core application code (config, security, etc.) +│ ├── db # Database-related code (session, migrations) +│ ├── models # SQLAlchemy models +│ ├── schemas # Pydantic schemas +│ └── services # Business logic services +├── migrations # Alembic migrations +├── alembic.ini # Alembic configuration +├── main.py # Application entry point +└── requirements.txt # Python dependencies +``` + +## Getting Started + +### Prerequisites + +- Python 3.8 or higher +- pip (Python package manager) + +### Installation + +1. Clone the repository: + ``` + git clone + cd logisticsmanagementsystem + ``` + +2. Create a virtual environment (optional but recommended): + ``` + python -m venv venv + source venv/bin/activate # On Windows, use: venv\Scripts\activate + ``` + +3. Install dependencies: + ``` + pip install -r requirements.txt + ``` + +4. Run database migrations: + ``` + alembic upgrade head + ``` + +5. Start the development server: + ``` + uvicorn main:app --reload + ``` + +The API will be available at `http://localhost:8000`. + +## API Documentation + +Once the server is running, you can access the interactive API documentation: + +- Swagger UI: `http://localhost:8000/docs` +- ReDoc: `http://localhost:8000/redoc` + +## Key Endpoints + +- **Authentication**: + - `POST /api/v1/auth/login`: Login and obtain access token + - `POST /api/v1/auth/register`: Register a new user + +- **Users**: + - `GET /api/v1/users/me`: Get current user information + - `PUT /api/v1/users/me`: Update current user information + +- **Products**: + - `GET /api/v1/products`: List all products + - `POST /api/v1/products`: Create a new product + - `GET /api/v1/products/{id}`: Get product details + +- **Warehouses**: + - `GET /api/v1/warehouses`: List all warehouses + - `POST /api/v1/warehouses`: Create a new warehouse + - `GET /api/v1/warehouses/{id}`: Get warehouse details + +- **Inventory**: + - `GET /api/v1/inventory`: List all inventory items + - `GET /api/v1/inventory/low-stock`: Get items with low stock levels + - `POST /api/v1/inventory`: Create a new inventory item + +- **Orders**: + - `GET /api/v1/orders`: List all orders + - `POST /api/v1/orders`: Create a new order + - `GET /api/v1/orders/{id}`: Get order details + +- **Shipments**: + - `GET /api/v1/shipments`: List all shipments + - `POST /api/v1/shipments`: Create a new shipment + - `GET /api/v1/shipments/{id}`: Get shipment details + - `GET /api/v1/shipments/tracking/{tracking_number}`: Track a shipment + +- **Health**: + - `GET /health`: Check API and database health + - `GET /api/v1/health`: Detailed API health check + +## Running in Production + +For production deployment: + +1. Set up a proper database (SQLite is for development only) +2. Change the `SECRET_KEY` to a secure random string +3. Configure CORS settings for your frontend domain +4. Use a proper ASGI server like Uvicorn or Gunicorn + +Example production start command: +``` +uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 +``` + +## License + +This project is licensed under the MIT License. \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..884993d --- /dev/null +++ b/alembic.ini @@ -0,0 +1,85 @@ +# 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 URL example - using absolute path +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 + +# 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..ec1381f --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# Logistics Management System API \ No newline at end of file diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..38e5c89 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +# API module \ No newline at end of file diff --git a/app/api/api.py b/app/api/api.py new file mode 100644 index 0000000..68c085b --- /dev/null +++ b/app/api/api.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter + +from app.api.endpoints import ( + auth, + health, + inventory, + orders, + products, + shipments, + users, + warehouses, +) + +api_router = APIRouter() +api_router.include_router(auth.router, prefix="/auth", tags=["Authentication"]) +api_router.include_router(users.router, prefix="/users", tags=["Users"]) +api_router.include_router(products.router, prefix="/products", tags=["Products"]) +api_router.include_router(warehouses.router, prefix="/warehouses", tags=["Warehouses"]) +api_router.include_router(inventory.router, prefix="/inventory", tags=["Inventory"]) +api_router.include_router(shipments.router, prefix="/shipments", tags=["Shipments"]) +api_router.include_router(orders.router, prefix="/orders", tags=["Orders"]) +api_router.include_router(health.router, prefix="/health", tags=["Health"]) \ No newline at end of file diff --git a/app/api/endpoints/__init__.py b/app/api/endpoints/__init__.py new file mode 100644 index 0000000..9320016 --- /dev/null +++ b/app/api/endpoints/__init__.py @@ -0,0 +1 @@ +# API endpoints \ No newline at end of file diff --git a/app/api/endpoints/auth.py b/app/api/endpoints/auth.py new file mode 100644 index 0000000..457671d --- /dev/null +++ b/app/api/endpoints/auth.py @@ -0,0 +1,79 @@ +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.core.auth import create_access_token +from app.core.security import ACCESS_TOKEN_EXPIRE_MINUTES +from app.db.session import get_db +from app.schemas.token import Token +from app.schemas.user import User, UserCreate +from app.services import user as user_service + +router = APIRouter() + + +@router.post("/login", 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 + """ + user = user_service.authenticate( + db, username_or_email=form_data.username, password=form_data.password + ) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username/email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + elif not user_service.is_active(user): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Inactive user", + headers={"WWW-Authenticate": "Bearer"}, + ) + + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + return { + "access_token": create_access_token( + sub=user.id, expires_delta=access_token_expires + ), + "token_type": "bearer", + } + + +@router.post("/register", response_model=User) +def register_user( + *, + db: Session = Depends(get_db), + user_in: UserCreate, +) -> Any: + """ + Register a new user + """ + # Check if user with this email already exists + user = user_service.get_by_email(db, email=user_in.email) + if user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="The user with this email already exists", + ) + + # Check if user with this username already exists + user = user_service.get_by_username(db, username=user_in.username) + if user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="The user with this username already exists", + ) + + # Regular users cannot create a superuser + user_in.is_superuser = False + + user = user_service.create(db, obj_in=user_in) + return user \ No newline at end of file diff --git a/app/api/endpoints/health.py b/app/api/endpoints/health.py new file mode 100644 index 0000000..09ed626 --- /dev/null +++ b/app/api/endpoints/health.py @@ -0,0 +1,33 @@ +from typing import Any + +from fastapi import APIRouter, Depends, status +from sqlalchemy.orm import Session +from sqlalchemy import text + +from app.db.session import get_db + +router = APIRouter() + + +@router.get("/", status_code=status.HTTP_200_OK) +def health_check(db: Session = Depends(get_db)) -> dict[str, Any]: + """ + Health check endpoint to verify the application is running correctly + and database connection is working. + """ + health_data = { + "status": "ok", + "api": "healthy", + "database": "healthy" + } + + # Check database connectivity + try: + # Execute a simple query + db.execute(text("SELECT 1")) + except Exception as e: + health_data["database"] = "unhealthy" + health_data["status"] = "error" + health_data["database_error"] = str(e) + + return health_data \ No newline at end of file diff --git a/app/api/endpoints/inventory.py b/app/api/endpoints/inventory.py new file mode 100644 index 0000000..8866a39 --- /dev/null +++ b/app/api/endpoints/inventory.py @@ -0,0 +1,172 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.core.deps import get_current_active_user, get_current_superuser +from app.db.session import get_db +from app.models.user import User +from app.schemas.inventory import ( + Inventory, + InventoryCreate, + InventoryUpdate, + InventoryWithDetails, +) +from app.services import inventory as inventory_service + +router = APIRouter() + + +@router.get("/", response_model=List[Inventory]) +def read_inventory( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + warehouse_id: int = Query(None, description="Filter by warehouse ID"), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Retrieve inventory items. + """ + inventory = inventory_service.get_multi( + db, skip=skip, limit=limit, warehouse_id=warehouse_id + ) + return inventory + + +@router.get("/with-details", response_model=List[InventoryWithDetails]) +def read_inventory_with_details( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + warehouse_id: int = Query(None, description="Filter by warehouse ID"), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Retrieve inventory items with product and warehouse details. + """ + results = inventory_service.get_multi_with_details( + db, skip=skip, limit=limit, warehouse_id=warehouse_id + ) + + inventory_with_details = [] + for inventory_item, product_name, warehouse_name in results: + inventory_dict = {**inventory_item.__dict__} + inventory_dict["product_name"] = product_name + inventory_dict["warehouse_name"] = warehouse_name + inventory_with_details.append(inventory_dict) + + return inventory_with_details + + +@router.get("/low-stock", response_model=List[Inventory]) +def read_low_stock_inventory( + db: Session = Depends(get_db), + limit: int = 100, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Retrieve inventory items that are below minimum stock level. + """ + inventory = inventory_service.get_low_stock_items(db, limit=limit) + return inventory + + +@router.post("/", response_model=Inventory) +def create_inventory( + *, + db: Session = Depends(get_db), + inventory_in: InventoryCreate, + current_user: User = Depends(get_current_superuser), +) -> Any: + """ + Create new inventory item or update quantity if it already exists. + Requires superuser access. + """ + inventory = inventory_service.create(db, obj_in=inventory_in) + return inventory + + +@router.get("/{inventory_id}", response_model=Inventory) +def read_inventory_item( + *, + db: Session = Depends(get_db), + inventory_id: int, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Get inventory item by ID. + """ + inventory = inventory_service.get(db, inventory_id=inventory_id) + if not inventory: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Inventory item not found", + ) + return inventory + + +@router.get("/{inventory_id}/details", response_model=InventoryWithDetails) +def read_inventory_item_with_details( + *, + db: Session = Depends(get_db), + inventory_id: int, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Get inventory item by ID with product and warehouse details. + """ + result = inventory_service.get_with_details(db, inventory_id=inventory_id) + if not result: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Inventory item not found", + ) + + inventory, product_name, warehouse_name = result + inventory_dict = {**inventory.__dict__} + inventory_dict["product_name"] = product_name + inventory_dict["warehouse_name"] = warehouse_name + + return inventory_dict + + +@router.put("/{inventory_id}", response_model=Inventory) +def update_inventory( + *, + db: Session = Depends(get_db), + inventory_id: int, + inventory_in: InventoryUpdate, + current_user: User = Depends(get_current_superuser), +) -> Any: + """ + Update an inventory item. Requires superuser access. + """ + inventory = inventory_service.get(db, inventory_id=inventory_id) + if not inventory: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Inventory item not found", + ) + inventory = inventory_service.update(db, db_obj=inventory, obj_in=inventory_in) + return inventory + + +@router.delete("/{inventory_id}", response_model=Inventory) +def delete_inventory( + *, + db: Session = Depends(get_db), + inventory_id: int, + current_user: User = Depends(get_current_superuser), +) -> Any: + """ + Delete an inventory item. Requires superuser access. + """ + inventory = inventory_service.get(db, inventory_id=inventory_id) + if not inventory: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Inventory item not found", + ) + inventory = inventory_service.remove(db, inventory_id=inventory_id) + return inventory \ No newline at end of file diff --git a/app/api/endpoints/orders.py b/app/api/endpoints/orders.py new file mode 100644 index 0000000..61e4679 --- /dev/null +++ b/app/api/endpoints/orders.py @@ -0,0 +1,215 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException, Path, status +from sqlalchemy.orm import Session + +from app.core.deps import get_current_active_user +from app.db.session import get_db +from app.models.order import OrderStatus +from app.models.user import User +from app.schemas.order import Order, OrderCreate, OrderItem, OrderUpdate +from app.services import order as order_service + +router = APIRouter() + + +@router.get("/", response_model=List[Order]) +def read_orders( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Retrieve orders. + """ + if not current_user.is_superuser: + # Regular users can only see their own orders + orders = order_service.get_multi( + db, skip=skip, limit=limit, user_id=current_user.id + ) + else: + # Superusers can see all orders + orders = order_service.get_multi(db, skip=skip, limit=limit) + return orders + + +@router.post("/", response_model=Order) +def create_order( + *, + db: Session = Depends(get_db), + order_in: OrderCreate, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Create new order. + """ + # Regular users can only create orders for themselves + if not current_user.is_superuser and order_in.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only create orders for yourself", + ) + + try: + order = order_service.create(db, obj_in=order_in) + return order + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + +@router.get("/{order_id}", response_model=Order) +def read_order( + *, + db: Session = Depends(get_db), + order_id: int = Path(..., description="The ID of the order to get"), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Get order by ID. + """ + order = order_service.get(db, order_id=order_id) + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found", + ) + + # Regular users can only see their own orders + if not current_user.is_superuser and order.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + return order + + +@router.put("/{order_id}", response_model=Order) +def update_order( + *, + db: Session = Depends(get_db), + order_id: int = Path(..., description="The ID of the order to update"), + order_in: OrderUpdate, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Update an order. + """ + order = order_service.get(db, order_id=order_id) + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found", + ) + + # Regular users can only update their own orders + if not current_user.is_superuser and order.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + # Regular users cannot update orders that are already shipped or delivered + if not current_user.is_superuser and order.status in [OrderStatus.SHIPPED, OrderStatus.DELIVERED]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot update an order that has already been shipped or delivered", + ) + + order = order_service.update(db, db_obj=order, obj_in=order_in) + return order + + +@router.put("/{order_id}/status", response_model=Order) +def update_order_status( + *, + db: Session = Depends(get_db), + order_id: int = Path(..., description="The ID of the order to update"), + status: OrderStatus, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Update order status. + """ + order = order_service.get(db, order_id=order_id) + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found", + ) + + # Only superusers can change order status + if not current_user.is_superuser: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + order = order_service.update_status(db, order_id=order_id, status=status) + return order + + +@router.delete("/{order_id}/cancel", response_model=Order) +def cancel_order( + *, + db: Session = Depends(get_db), + order_id: int = Path(..., description="The ID of the order to cancel"), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Cancel an order if it hasn't been shipped yet. + """ + order = order_service.get(db, order_id=order_id) + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found", + ) + + # Regular users can only cancel their own orders + if not current_user.is_superuser and order.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + try: + order = order_service.cancel_order(db, order_id=order_id) + return order + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + +@router.get("/{order_id}/items", response_model=List[OrderItem]) +def read_order_items( + *, + db: Session = Depends(get_db), + order_id: int = Path(..., description="The ID of the order to get items for"), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Get all items for a specific order. + """ + order = order_service.get(db, order_id=order_id) + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found", + ) + + # Regular users can only see their own orders' items + if not current_user.is_superuser and order.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + items = order_service.get_order_items(db, order_id=order_id) + return items \ No newline at end of file diff --git a/app/api/endpoints/products.py b/app/api/endpoints/products.py new file mode 100644 index 0000000..ef2122e --- /dev/null +++ b/app/api/endpoints/products.py @@ -0,0 +1,109 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.core.deps import get_current_active_user, get_current_superuser +from app.db.session import get_db +from app.models.user import User +from app.schemas.product import Product, ProductCreate, ProductUpdate +from app.services import product as product_service + +router = APIRouter() + + +@router.get("/", response_model=List[Product]) +def read_products( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + active_only: bool = Query(False, description="Filter only active products"), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Retrieve products. + """ + products = product_service.get_multi( + db, skip=skip, limit=limit, active_only=active_only + ) + return products + + +@router.post("/", response_model=Product) +def create_product( + *, + db: Session = Depends(get_db), + product_in: ProductCreate, + current_user: User = Depends(get_current_superuser), +) -> Any: + """ + Create new product. Requires superuser access. + """ + product = product_service.get_by_sku(db, sku=product_in.sku) + if product: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A product with this SKU already exists", + ) + product = product_service.create(db, obj_in=product_in) + return product + + +@router.get("/{product_id}", response_model=Product) +def read_product( + *, + db: Session = Depends(get_db), + product_id: int, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Get product by ID. + """ + product = product_service.get(db, product_id=product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + return product + + +@router.put("/{product_id}", response_model=Product) +def update_product( + *, + db: Session = Depends(get_db), + product_id: int, + product_in: ProductUpdate, + current_user: User = Depends(get_current_superuser), +) -> Any: + """ + Update a product. Requires superuser access. + """ + product = product_service.get(db, product_id=product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + product = product_service.update(db, db_obj=product, obj_in=product_in) + return product + + +@router.delete("/{product_id}", response_model=Product) +def delete_product( + *, + db: Session = Depends(get_db), + product_id: int, + current_user: User = Depends(get_current_superuser), +) -> Any: + """ + Delete a product. Requires superuser access. + """ + product = product_service.get(db, product_id=product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + product = product_service.remove(db, product_id=product_id) + return product \ No newline at end of file diff --git a/app/api/endpoints/shipments.py b/app/api/endpoints/shipments.py new file mode 100644 index 0000000..5a1b54e --- /dev/null +++ b/app/api/endpoints/shipments.py @@ -0,0 +1,190 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException, Path, Query, status +from sqlalchemy.orm import Session + +from app.core.deps import get_current_active_user +from app.db.session import get_db +from app.models.shipment import ShipmentStatus +from app.models.user import User +from app.schemas.shipment import ( + Shipment, + ShipmentCreate, + ShipmentItem, + ShipmentTracking, + ShipmentUpdate, +) +from app.services import shipment as shipment_service + +router = APIRouter() + + +@router.get("/", response_model=List[Shipment]) +def read_shipments( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + order_id: int = Query(None, description="Filter by order ID"), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Retrieve shipments. + """ + shipments = shipment_service.get_multi( + db, skip=skip, limit=limit, order_id=order_id + ) + return shipments + + +@router.post("/", response_model=Shipment) +def create_shipment( + *, + db: Session = Depends(get_db), + shipment_in: ShipmentCreate, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Create new shipment. + """ + # Set current user as creator if not provided + if not shipment_in.created_by_id: + shipment_in.created_by_id = current_user.id + + # Only superusers can create shipments for other users + if not current_user.is_superuser and shipment_in.created_by_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You can only create shipments for yourself", + ) + + try: + shipment = shipment_service.create(db, obj_in=shipment_in) + return shipment + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + + +@router.get("/{shipment_id}", response_model=Shipment) +def read_shipment( + *, + db: Session = Depends(get_db), + shipment_id: int = Path(..., description="The ID of the shipment to get"), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Get shipment by ID. + """ + shipment = shipment_service.get(db, shipment_id=shipment_id) + if not shipment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Shipment not found", + ) + return shipment + + +@router.get("/tracking/{tracking_number}", response_model=ShipmentTracking) +def read_shipment_by_tracking( + *, + db: Session = Depends(get_db), + tracking_number: str = Path(..., description="The tracking number of the shipment"), +) -> Any: + """ + Get shipment tracking information by tracking number. This endpoint is public. + """ + shipment = shipment_service.get_by_tracking_number(db, tracking_number=tracking_number) + if not shipment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Shipment not found", + ) + + return { + "tracking_number": shipment.tracking_number, + "status": shipment.status, + "carrier": shipment.carrier, + "estimated_delivery": shipment.estimated_delivery, + "actual_delivery": shipment.actual_delivery, + } + + +@router.put("/{shipment_id}", response_model=Shipment) +def update_shipment( + *, + db: Session = Depends(get_db), + shipment_id: int = Path(..., description="The ID of the shipment to update"), + shipment_in: ShipmentUpdate, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Update a shipment. + """ + shipment = shipment_service.get(db, shipment_id=shipment_id) + if not shipment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Shipment not found", + ) + + # Only superusers or the creator can update shipments + if not current_user.is_superuser and shipment.created_by_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + shipment = shipment_service.update(db, db_obj=shipment, obj_in=shipment_in) + return shipment + + +@router.put("/{shipment_id}/status", response_model=Shipment) +def update_shipment_status( + *, + db: Session = Depends(get_db), + shipment_id: int = Path(..., description="The ID of the shipment to update"), + status: ShipmentStatus, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Update shipment status. + """ + shipment = shipment_service.get(db, shipment_id=shipment_id) + if not shipment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Shipment not found", + ) + + # Only superusers or the creator can update shipment status + if not current_user.is_superuser and shipment.created_by_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + + shipment = shipment_service.update_status(db, shipment_id=shipment_id, status=status) + return shipment + + +@router.get("/{shipment_id}/items", response_model=List[ShipmentItem]) +def read_shipment_items( + *, + db: Session = Depends(get_db), + shipment_id: int = Path(..., description="The ID of the shipment to get items for"), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Get all items for a specific shipment. + """ + shipment = shipment_service.get(db, shipment_id=shipment_id) + if not shipment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Shipment not found", + ) + + items = shipment_service.get_shipment_items(db, shipment_id=shipment_id) + return items \ No newline at end of file diff --git a/app/api/endpoints/users.py b/app/api/endpoints/users.py new file mode 100644 index 0000000..57bd081 --- /dev/null +++ b/app/api/endpoints/users.py @@ -0,0 +1,156 @@ +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.core.deps import get_current_active_user, get_current_superuser +from app.db.session import get_db +from app.models.user import User +from app.schemas.user import User as UserSchema +from app.schemas.user import UserCreate, UserUpdate +from app.services import user as user_service + +router = APIRouter() + + +@router.get("/", response_model=List[UserSchema]) +def read_users( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + current_user: User = Depends(get_current_superuser), +) -> Any: + """ + Retrieve users. Requires superuser access. + """ + users = user_service.get_multi(db, skip=skip, limit=limit) + return users + + +@router.post("/", response_model=UserSchema) +def create_user( + *, + db: Session = Depends(get_db), + user_in: UserCreate, + current_user: User = Depends(get_current_superuser), +) -> Any: + """ + Create new user. Requires superuser access. + """ + user = user_service.get_by_email(db, email=user_in.email) + if user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="The user with this email already exists", + ) + user = user_service.get_by_username(db, username=user_in.username) + if user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="The user with this username already exists", + ) + user = user_service.create(db, obj_in=user_in) + return user + + +@router.get("/me", response_model=UserSchema) +def read_user_me( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Get current user. + """ + return current_user + + +@router.put("/me", response_model=UserSchema) +def update_user_me( + *, + db: Session = Depends(get_db), + password: str = Body(None), + full_name: str = Body(None), + email: EmailStr = Body(None), + current_user: User = 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 + user = user_service.update(db, db_obj=current_user, obj_in=user_in) + return user + + +@router.get("/{user_id}", response_model=UserSchema) +def read_user_by_id( + user_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db), +) -> Any: + """ + Get a specific user by id. + """ + user = user_service.get(db, user_id=user_id) + if user == current_user: + return user + if not user_service.is_superuser(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + return user + + +@router.put("/{user_id}", response_model=UserSchema) +def update_user( + *, + db: Session = Depends(get_db), + user_id: int, + user_in: UserUpdate, + current_user: User = Depends(get_current_superuser), +) -> Any: + """ + Update a user. Requires superuser access. + """ + user = user_service.get(db, user_id=user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + user = user_service.update(db, db_obj=user, obj_in=user_in) + return user + + +@router.delete("/{user_id}", response_model=UserSchema) +def delete_user( + *, + db: Session = Depends(get_db), + user_id: int, + current_user: User = Depends(get_current_superuser), +) -> Any: + """ + Delete a user. Requires superuser access. + """ + user = user_service.get(db, user_id=user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + if user.id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete self", + ) + user = user_service.remove(db, user_id=user_id) + return user \ No newline at end of file diff --git a/app/api/endpoints/warehouses.py b/app/api/endpoints/warehouses.py new file mode 100644 index 0000000..d0a0db7 --- /dev/null +++ b/app/api/endpoints/warehouses.py @@ -0,0 +1,109 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.core.deps import get_current_active_user, get_current_superuser +from app.db.session import get_db +from app.models.user import User +from app.schemas.warehouse import Warehouse, WarehouseCreate, WarehouseUpdate +from app.services import warehouse as warehouse_service + +router = APIRouter() + + +@router.get("/", response_model=List[Warehouse]) +def read_warehouses( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + active_only: bool = Query(False, description="Filter only active warehouses"), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Retrieve warehouses. + """ + warehouses = warehouse_service.get_multi( + db, skip=skip, limit=limit, active_only=active_only + ) + return warehouses + + +@router.post("/", response_model=Warehouse) +def create_warehouse( + *, + db: Session = Depends(get_db), + warehouse_in: WarehouseCreate, + current_user: User = Depends(get_current_superuser), +) -> Any: + """ + Create new warehouse. Requires superuser access. + """ + warehouse = warehouse_service.get_by_code(db, code=warehouse_in.code) + if warehouse: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A warehouse with this code already exists", + ) + warehouse = warehouse_service.create(db, obj_in=warehouse_in) + return warehouse + + +@router.get("/{warehouse_id}", response_model=Warehouse) +def read_warehouse( + *, + db: Session = Depends(get_db), + warehouse_id: int, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Get warehouse by ID. + """ + warehouse = warehouse_service.get(db, warehouse_id=warehouse_id) + if not warehouse: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Warehouse not found", + ) + return warehouse + + +@router.put("/{warehouse_id}", response_model=Warehouse) +def update_warehouse( + *, + db: Session = Depends(get_db), + warehouse_id: int, + warehouse_in: WarehouseUpdate, + current_user: User = Depends(get_current_superuser), +) -> Any: + """ + Update a warehouse. Requires superuser access. + """ + warehouse = warehouse_service.get(db, warehouse_id=warehouse_id) + if not warehouse: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Warehouse not found", + ) + warehouse = warehouse_service.update(db, db_obj=warehouse, obj_in=warehouse_in) + return warehouse + + +@router.delete("/{warehouse_id}", response_model=Warehouse) +def delete_warehouse( + *, + db: Session = Depends(get_db), + warehouse_id: int, + current_user: User = Depends(get_current_superuser), +) -> Any: + """ + Delete a warehouse. Requires superuser access. + """ + warehouse = warehouse_service.get(db, warehouse_id=warehouse_id) + if not warehouse: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Warehouse not found", + ) + warehouse = warehouse_service.remove(db, warehouse_id=warehouse_id) + return warehouse \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..84130b5 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +# Core functionality \ No newline at end of file diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..71a40b9 --- /dev/null +++ b/app/core/auth.py @@ -0,0 +1,70 @@ +from datetime import datetime, timedelta +from typing import Any, Optional, Union + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app.core.security import ACCESS_TOKEN_EXPIRE_MINUTES, ALGORITHM, SECRET_KEY +from app.db.session import get_db +from app.models.user import User +from app.schemas.token import TokenPayload +from app.services import user as user_service + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +def create_access_token(*, sub: Union[str, Any], expires_delta: Optional[timedelta] = None) -> str: + """ + Create a JWT access token for authentication + """ + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode = {"exp": expire, "sub": str(sub)} + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def get_current_user(db: Session = Depends(get_db), token: str = Depends(oauth2_scheme)) -> User: + """ + Validate access token and return current user + """ + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + token_data = TokenPayload(**payload) + except (JWTError, ValidationError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = user_service.get(db, user_id=token_data.sub) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return user + + +def get_current_active_user(current_user: User = Depends(get_current_user)) -> User: + """ + Validate that the current user is active + """ + if not user_service.is_active(current_user): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user") + return current_user + + +def get_current_superuser(current_user: User = Depends(get_current_active_user)) -> User: + """ + Validate that the current user is a superuser + """ + if not user_service.is_superuser(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions" + ) + 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..938ca75 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,37 @@ +from pathlib import Path +from typing import List, Union + +from pydantic import AnyHttpUrl, Field, validator +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + API_V1_STR: str = "/api/v1" + PROJECT_NAME: str = "Logistics Management System" + + # CORS configuration + 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) + + # Database settings + DB_DIR: Path = Path("/app") / "storage" / "db" + SQLALCHEMY_DATABASE_URL: str = Field( + default_factory=lambda: f"sqlite:///{Settings.DB_DIR}/db.sqlite" + ) + + class Config: + case_sensitive = True + env_file = ".env" + + +settings = Settings() + +# Create the database directory if it doesn't exist +settings.DB_DIR.mkdir(parents=True, exist_ok=True) \ No newline at end of file diff --git a/app/core/deps.py b/app/core/deps.py new file mode 100644 index 0000000..75d3479 --- /dev/null +++ b/app/core/deps.py @@ -0,0 +1,50 @@ + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app.core.security import ALGORITHM, SECRET_KEY +from app.db.session import get_db +from app.models.user import User +from app.schemas.token import TokenPayload +from app.services import user as user_service + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +def get_current_user( + db: Session = Depends(get_db), token: str = Depends(oauth2_scheme) +) -> User: + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + token_data = TokenPayload(**payload) + except (JWTError, ValidationError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + user = user_service.get(db, user_id=token_data.sub) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) + return user + + +def get_current_active_user(current_user: User = Depends(get_current_user)) -> User: + if not user_service.is_active(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" + ) + return current_user + + +def get_current_superuser(current_user: User = Depends(get_current_active_user)) -> User: + if not user_service.is_superuser(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions" + ) + return current_user \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..20709fe --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,32 @@ +from datetime import datetime, timedelta +from typing import Any, Union + +from jose import jwt +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# These values would typically be set in the environment or config +SECRET_KEY = "CHANGEME_IN_PRODUCTION_THIS_IS_A_PLACEHOLDER_SECRET" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + + +def create_access_token( + subject: Union[str, Any], expires_delta: timedelta = None +) -> str: + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) \ No newline at end of file diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..2f0b25d --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ +# Database module \ No newline at end of file diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..2d1e901 --- /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.session import Base # noqa +from app.models.user import User # noqa +from app.models.product import Product # noqa +from app.models.warehouse import Warehouse # noqa +from app.models.inventory import Inventory # noqa +from app.models.shipment import Shipment # noqa +from app.models.order import Order # noqa \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..a30a905 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,21 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +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) + +Base = declarative_base() + +# Dependency to get DB session +def get_db(): + 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..ffa92f9 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ +# Database models \ No newline at end of file diff --git a/app/models/inventory.py b/app/models/inventory.py new file mode 100644 index 0000000..25f2f46 --- /dev/null +++ b/app/models/inventory.py @@ -0,0 +1,25 @@ +from sqlalchemy import Column, ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy.orm import relationship + +from app.db.session import Base + + +class Inventory(Base): + __tablename__ = "inventory" + + id = Column(Integer, primary_key=True, index=True) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False) + quantity = Column(Integer, nullable=False, default=0) + location = Column(String) # Specific location within the warehouse (e.g., "Aisle 5, Shelf B") + min_stock_level = Column(Integer, default=0) + max_stock_level = Column(Integer, default=0) + + # Ensure a product can only have one inventory record per warehouse + __table_args__ = ( + UniqueConstraint('product_id', 'warehouse_id', name='uix_product_warehouse'), + ) + + # Relationships + product = relationship("Product", back_populates="inventory_items") + warehouse = relationship("Warehouse", back_populates="inventory") \ No newline at end of file diff --git a/app/models/order.py b/app/models/order.py new file mode 100644 index 0000000..71228a8 --- /dev/null +++ b/app/models/order.py @@ -0,0 +1,48 @@ +import enum + +from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, Integer, String +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.db.session import Base + + +class OrderStatus(str, enum.Enum): + PENDING = "pending" + PROCESSING = "processing" + SHIPPED = "shipped" + DELIVERED = "delivered" + CANCELLED = "cancelled" + + +class Order(Base): + __tablename__ = "orders" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + order_number = Column(String, unique=True, index=True, nullable=False) + status = Column(Enum(OrderStatus), default=OrderStatus.PENDING) + total_amount = Column(Float, nullable=False, default=0.0) + shipping_address = Column(String, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + user = relationship("User", back_populates="orders") + order_items = relationship("OrderItem", back_populates="order") + shipments = relationship("Shipment", back_populates="order") + + +class OrderItem(Base): + __tablename__ = "order_items" + + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + quantity = Column(Integer, nullable=False, default=1) + unit_price = Column(Float, nullable=False) + total_price = Column(Float, nullable=False) + + # Relationships + order = relationship("Order", back_populates="order_items") + product = relationship("Product", back_populates="order_items") \ No newline at end of file diff --git a/app/models/product.py b/app/models/product.py new file mode 100644 index 0000000..fadd586 --- /dev/null +++ b/app/models/product.py @@ -0,0 +1,22 @@ +from sqlalchemy import Boolean, Column, Float, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.db.session import Base + + +class Product(Base): + __tablename__ = "products" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True, nullable=False) + sku = Column(String, unique=True, index=True, nullable=False) + description = Column(Text) + weight = Column(Float, nullable=False, default=0.0) # in kg + dimensions = Column(String) # format: "length x width x height" in cm + price = Column(Float, nullable=False, default=0.0) + is_active = Column(Boolean, default=True) + + # Relationships + inventory_items = relationship("Inventory", back_populates="product") + order_items = relationship("OrderItem", back_populates="product") + shipment_items = relationship("ShipmentItem", back_populates="product") \ No newline at end of file diff --git a/app/models/shipment.py b/app/models/shipment.py new file mode 100644 index 0000000..8a4498f --- /dev/null +++ b/app/models/shipment.py @@ -0,0 +1,54 @@ +import enum + +from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.db.session import Base + + +class ShipmentStatus(str, enum.Enum): + PENDING = "pending" + PROCESSING = "processing" + IN_TRANSIT = "in_transit" + DELIVERED = "delivered" + CANCELLED = "cancelled" + + +class Shipment(Base): + __tablename__ = "shipments" + + id = Column(Integer, primary_key=True, index=True) + tracking_number = Column(String, unique=True, index=True, nullable=False) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=True) # Optional - can be internal movement + origin_warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False) + destination_warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=True) # Null for direct customer delivery + customer_address = Column(Text, nullable=True) # Required if destination_warehouse_id is null + status = Column(Enum(ShipmentStatus), default=ShipmentStatus.PENDING) + carrier = Column(String, nullable=True) + estimated_delivery = Column(DateTime(timezone=True), nullable=True) + actual_delivery = Column(DateTime(timezone=True), nullable=True) + shipping_cost = Column(Float, default=0.0) + created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + order = relationship("Order", back_populates="shipments") + origin_warehouse = relationship("Warehouse", foreign_keys=[origin_warehouse_id], back_populates="shipments_from") + destination_warehouse = relationship("Warehouse", foreign_keys=[destination_warehouse_id], back_populates="shipments_to") + created_by = relationship("User", back_populates="shipments") + items = relationship("ShipmentItem", back_populates="shipment") + + +class ShipmentItem(Base): + __tablename__ = "shipment_items" + + id = Column(Integer, primary_key=True, index=True) + shipment_id = Column(Integer, ForeignKey("shipments.id"), nullable=False) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + quantity = Column(Integer, nullable=False, default=1) + + # Relationships + shipment = relationship("Shipment", back_populates="items") + product = relationship("Product", back_populates="shipment_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..66697c4 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,20 @@ +from sqlalchemy import Boolean, Column, Integer, String +from sqlalchemy.orm import relationship + +from app.db.session import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + username = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) + full_name = Column(String) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + + # Relationships + orders = relationship("Order", back_populates="user") + shipments = relationship("Shipment", back_populates="created_by") \ No newline at end of file diff --git a/app/models/warehouse.py b/app/models/warehouse.py new file mode 100644 index 0000000..1fcc639 --- /dev/null +++ b/app/models/warehouse.py @@ -0,0 +1,23 @@ +from sqlalchemy import Boolean, Column, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.db.session import Base + + +class Warehouse(Base): + __tablename__ = "warehouses" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True, nullable=False) + code = Column(String, unique=True, index=True, nullable=False) + address = Column(Text, nullable=False) + city = Column(String, nullable=False) + state = Column(String, nullable=False) + country = Column(String, nullable=False) + postal_code = Column(String, nullable=False) + is_active = Column(Boolean, default=True) + + # Relationships + inventory = relationship("Inventory", back_populates="warehouse") + shipments_from = relationship("Shipment", foreign_keys="Shipment.origin_warehouse_id", back_populates="origin_warehouse") + shipments_to = relationship("Shipment", foreign_keys="Shipment.destination_warehouse_id", back_populates="destination_warehouse") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..fef16e6 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,34 @@ +# Import all schemas here for convenience +from app.schemas.inventory import ( + Inventory, + InventoryCreate, + InventoryInDB, + InventoryUpdate, + InventoryWithDetails, +) +from app.schemas.order import ( + Order, + OrderCreate, + OrderInDB, + OrderItem, + OrderItemCreate, + OrderUpdate, +) +from app.schemas.product import Product, ProductCreate, ProductInDB, ProductUpdate +from app.schemas.shipment import ( + Shipment, + ShipmentCreate, + ShipmentInDB, + ShipmentItem, + ShipmentItemCreate, + ShipmentTracking, + ShipmentUpdate, +) +from app.schemas.token import Token, TokenPayload +from app.schemas.user import User, UserCreate, UserInDB, UserUpdate +from app.schemas.warehouse import ( + Warehouse, + WarehouseCreate, + WarehouseInDB, + WarehouseUpdate, +) diff --git a/app/schemas/inventory.py b/app/schemas/inventory.py new file mode 100644 index 0000000..7935c8c --- /dev/null +++ b/app/schemas/inventory.py @@ -0,0 +1,48 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +# Shared properties +class InventoryBase(BaseModel): + product_id: Optional[int] = None + warehouse_id: Optional[int] = None + quantity: Optional[int] = Field(0, ge=0) + location: Optional[str] = None + min_stock_level: Optional[int] = Field(0, ge=0) + max_stock_level: Optional[int] = Field(0, ge=0) + + +# Properties to receive via API on creation +class InventoryCreate(InventoryBase): + product_id: int + warehouse_id: int + quantity: int + + +# Properties to receive via API on update +class InventoryUpdate(InventoryBase): + pass + + +class InventoryInDBBase(InventoryBase): + id: int + + class Config: + from_attributes = True + + +# Additional properties to return via API +class Inventory(InventoryInDBBase): + pass + + +# Additional properties stored in DB +class InventoryInDB(InventoryInDBBase): + pass + + +# Inventory with related product and warehouse information +class InventoryWithDetails(Inventory): + product_name: str + warehouse_name: str \ No newline at end of file diff --git a/app/schemas/order.py b/app/schemas/order.py new file mode 100644 index 0000000..6d671e1 --- /dev/null +++ b/app/schemas/order.py @@ -0,0 +1,74 @@ +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + +from app.models.order import OrderStatus + + +# Order Item Schema +class OrderItemBase(BaseModel): + product_id: int + quantity: int = Field(1, gt=0) + unit_price: Optional[float] = None + + +class OrderItemCreate(OrderItemBase): + pass + + +class OrderItemUpdate(OrderItemBase): + pass + + +class OrderItemInDBBase(OrderItemBase): + id: int + order_id: int + total_price: float + + class Config: + from_attributes = True + + +class OrderItem(OrderItemInDBBase): + pass + + +class OrderItemInDB(OrderItemInDBBase): + pass + + +# Order Schema +class OrderBase(BaseModel): + user_id: Optional[int] = None + shipping_address: Optional[str] = None + status: Optional[OrderStatus] = OrderStatus.PENDING + + +class OrderCreate(OrderBase): + user_id: int + shipping_address: str + items: List[OrderItemCreate] + + +class OrderUpdate(OrderBase): + pass + + +class OrderInDBBase(OrderBase): + id: int + order_number: str + total_amount: float + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class Order(OrderInDBBase): + items: List[OrderItem] = [] + + +class OrderInDB(OrderInDBBase): + 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..b82e7e0 --- /dev/null +++ b/app/schemas/product.py @@ -0,0 +1,43 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +# Shared properties +class ProductBase(BaseModel): + name: Optional[str] = None + sku: Optional[str] = None + description: Optional[str] = None + weight: Optional[float] = Field(None, description="Weight in kg") + dimensions: Optional[str] = Field(None, description="Format: 'length x width x height' in cm") + price: Optional[float] = None + is_active: Optional[bool] = True + + +# Properties to receive via API on creation +class ProductCreate(ProductBase): + name: str + sku: str + price: float + + +# Properties to receive via API on update +class ProductUpdate(ProductBase): + pass + + +class ProductInDBBase(ProductBase): + id: int + + class Config: + from_attributes = True + + +# Additional properties to return via API +class Product(ProductInDBBase): + pass + + +# Additional properties stored in DB +class ProductInDB(ProductInDBBase): + pass \ No newline at end of file diff --git a/app/schemas/shipment.py b/app/schemas/shipment.py new file mode 100644 index 0000000..ae49a1a --- /dev/null +++ b/app/schemas/shipment.py @@ -0,0 +1,89 @@ +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + +from app.models.shipment import ShipmentStatus + + +# Shipment Item Schema +class ShipmentItemBase(BaseModel): + product_id: int + quantity: int = Field(1, gt=0) + + +class ShipmentItemCreate(ShipmentItemBase): + pass + + +class ShipmentItemUpdate(ShipmentItemBase): + pass + + +class ShipmentItemInDBBase(ShipmentItemBase): + id: int + shipment_id: int + + class Config: + from_attributes = True + + +class ShipmentItem(ShipmentItemInDBBase): + pass + + +class ShipmentItemInDB(ShipmentItemInDBBase): + pass + + +# Shipment Schema +class ShipmentBase(BaseModel): + order_id: Optional[int] = None + origin_warehouse_id: Optional[int] = None + destination_warehouse_id: Optional[int] = None + customer_address: Optional[str] = None + status: Optional[ShipmentStatus] = ShipmentStatus.PENDING + carrier: Optional[str] = None + estimated_delivery: Optional[datetime] = None + shipping_cost: Optional[float] = 0.0 + created_by_id: Optional[int] = None + + +class ShipmentCreate(ShipmentBase): + origin_warehouse_id: int + created_by_id: int + items: List[ShipmentItemCreate] + # Either destination_warehouse_id or customer_address must be provided + # This will be validated in the service layer + + +class ShipmentUpdate(ShipmentBase): + pass + + +class ShipmentInDBBase(ShipmentBase): + id: int + tracking_number: str + actual_delivery: Optional[datetime] = None + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class Shipment(ShipmentInDBBase): + items: List[ShipmentItem] = [] + + +class ShipmentInDB(ShipmentInDBBase): + pass + + +# Schema for tracking info that can be shared publicly +class ShipmentTracking(BaseModel): + tracking_number: str + status: ShipmentStatus + carrier: Optional[str] = None + estimated_delivery: Optional[datetime] = None + actual_delivery: Optional[datetime] = None \ No newline at end of file diff --git a/app/schemas/token.py b/app/schemas/token.py new file mode 100644 index 0000000..62e2a78 --- /dev/null +++ b/app/schemas/token.py @@ -0,0 +1,13 @@ +from typing import Optional + +from pydantic import BaseModel + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenPayload(BaseModel): + sub: Optional[int] = None + is_superuser: bool = False \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..121378b --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,41 @@ +from typing import Optional + +from pydantic import BaseModel, EmailStr + + +# Shared properties +class UserBase(BaseModel): + email: Optional[EmailStr] = None + username: Optional[str] = None + is_active: Optional[bool] = True + is_superuser: bool = False + full_name: Optional[str] = None + + +# Properties to receive via API on creation +class UserCreate(UserBase): + email: EmailStr + username: str + password: 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 but not returned by API +class UserInDB(UserInDBBase): + hashed_password: str \ No newline at end of file diff --git a/app/schemas/warehouse.py b/app/schemas/warehouse.py new file mode 100644 index 0000000..10ee2b9 --- /dev/null +++ b/app/schemas/warehouse.py @@ -0,0 +1,48 @@ +from typing import Optional + +from pydantic import BaseModel + + +# Shared properties +class WarehouseBase(BaseModel): + name: Optional[str] = None + code: Optional[str] = None + address: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + country: Optional[str] = None + postal_code: Optional[str] = None + is_active: Optional[bool] = True + + +# Properties to receive via API on creation +class WarehouseCreate(WarehouseBase): + name: str + code: str + address: str + city: str + state: str + country: str + postal_code: str + + +# Properties to receive via API on update +class WarehouseUpdate(WarehouseBase): + pass + + +class WarehouseInDBBase(WarehouseBase): + id: int + + class Config: + from_attributes = True + + +# Additional properties to return via API +class Warehouse(WarehouseInDBBase): + pass + + +# Additional properties stored in DB +class WarehouseInDB(WarehouseInDBBase): + pass \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..fea2895 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# Business logic services \ No newline at end of file diff --git a/app/services/inventory.py b/app/services/inventory.py new file mode 100644 index 0000000..a05c16c --- /dev/null +++ b/app/services/inventory.py @@ -0,0 +1,138 @@ +from typing import Any, Dict, List, Optional, Tuple, Union + +from sqlalchemy.orm import Session + +from app.models.inventory import Inventory +from app.models.product import Product +from app.models.warehouse import Warehouse +from app.schemas.inventory import InventoryCreate, InventoryUpdate + + +def get(db: Session, inventory_id: int) -> Optional[Inventory]: + return db.query(Inventory).filter(Inventory.id == inventory_id).first() + + +def get_by_product_and_warehouse( + db: Session, *, product_id: int, warehouse_id: int +) -> Optional[Inventory]: + return ( + db.query(Inventory) + .filter(Inventory.product_id == product_id, Inventory.warehouse_id == warehouse_id) + .first() + ) + + +def get_multi( + db: Session, *, skip: int = 0, limit: int = 100, warehouse_id: Optional[int] = None +) -> List[Inventory]: + query = db.query(Inventory) + if warehouse_id: + query = query.filter(Inventory.warehouse_id == warehouse_id) + return query.offset(skip).limit(limit).all() + + +def create(db: Session, *, obj_in: InventoryCreate) -> Inventory: + # Check if inventory already exists for this product/warehouse combination + existing = get_by_product_and_warehouse( + db, product_id=obj_in.product_id, warehouse_id=obj_in.warehouse_id + ) + if existing: + # Update quantity instead of creating new + existing.quantity += obj_in.quantity + db.add(existing) + db.commit() + db.refresh(existing) + return existing + + # Create new inventory record + db_obj = Inventory( + product_id=obj_in.product_id, + warehouse_id=obj_in.warehouse_id, + quantity=obj_in.quantity, + location=obj_in.location, + min_stock_level=obj_in.min_stock_level, + max_stock_level=obj_in.max_stock_level, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def update( + db: Session, *, db_obj: Inventory, obj_in: Union[InventoryUpdate, Dict[str, Any]] +) -> Inventory: + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.dict(exclude_unset=True) + for field in update_data: + setattr(db_obj, field, update_data[field]) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def remove(db: Session, *, inventory_id: int) -> Inventory: + obj = db.query(Inventory).get(inventory_id) + db.delete(obj) + db.commit() + return obj + + +def get_with_details(db: Session, inventory_id: int) -> Optional[Tuple[Inventory, str, str]]: + """Get inventory with product and warehouse names""" + result = ( + db.query(Inventory, Product.name, Warehouse.name) + .join(Product) + .join(Warehouse) + .filter(Inventory.id == inventory_id) + .first() + ) + if result: + inventory, product_name, warehouse_name = result + return inventory, product_name, warehouse_name + return None + + +def get_multi_with_details( + db: Session, *, skip: int = 0, limit: int = 100, warehouse_id: Optional[int] = None +) -> List[Tuple[Inventory, str, str]]: + """Get multiple inventory items with product and warehouse names""" + query = ( + db.query(Inventory, Product.name, Warehouse.name) + .join(Product) + .join(Warehouse) + ) + if warehouse_id: + query = query.filter(Inventory.warehouse_id == warehouse_id) + return query.offset(skip).limit(limit).all() + + +def update_quantity( + db: Session, *, inventory_id: int, quantity_change: int +) -> Optional[Inventory]: + """Update inventory quantity by adding the quantity_change (can be negative)""" + inventory = get(db, inventory_id) + if not inventory: + return None + + inventory.quantity += quantity_change + if inventory.quantity < 0: + inventory.quantity = 0 + + db.add(inventory) + db.commit() + db.refresh(inventory) + return inventory + + +def get_low_stock_items(db: Session, *, limit: int = 100) -> List[Inventory]: + """Get inventory items where quantity is below min_stock_level""" + return ( + db.query(Inventory) + .filter(Inventory.quantity < Inventory.min_stock_level) + .limit(limit) + .all() + ) \ No newline at end of file diff --git a/app/services/order.py b/app/services/order.py new file mode 100644 index 0000000..deed01f --- /dev/null +++ b/app/services/order.py @@ -0,0 +1,142 @@ +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional, Union + +from sqlalchemy.orm import Session + +from app.models.order import Order, OrderItem, OrderStatus +from app.models.product import Product +from app.schemas.order import OrderCreate, OrderUpdate + + +def generate_order_number() -> str: + """Generate a unique order number""" + # Format: ORD-{random_uuid} + return f"ORD-{uuid.uuid4().hex[:8].upper()}" + + +def get(db: Session, order_id: int) -> Optional[Order]: + return db.query(Order).filter(Order.id == order_id).first() + + +def get_by_order_number(db: Session, order_number: str) -> Optional[Order]: + return db.query(Order).filter(Order.order_number == order_number).first() + + +def get_multi( + db: Session, *, skip: int = 0, limit: int = 100, user_id: Optional[int] = None +) -> List[Order]: + query = db.query(Order) + if user_id: + query = query.filter(Order.user_id == user_id) + return query.order_by(Order.created_at.desc()).offset(skip).limit(limit).all() + + +def create(db: Session, *, obj_in: OrderCreate) -> Order: + # Calculate total order amount based on current product prices + total_amount = 0.0 + order_items = [] + + for item in obj_in.items: + product = db.query(Product).get(item.product_id) + if not product: + raise ValueError(f"Product with ID {item.product_id} not found") + + # Use provided unit price or get from product + unit_price = item.unit_price if item.unit_price is not None else product.price + item_total = unit_price * item.quantity + total_amount += item_total + + order_items.append({ + "product_id": item.product_id, + "quantity": item.quantity, + "unit_price": unit_price, + "total_price": item_total + }) + + # Create order + db_obj = Order( + user_id=obj_in.user_id, + order_number=generate_order_number(), + status=obj_in.status or OrderStatus.PENDING, + total_amount=total_amount, + shipping_address=obj_in.shipping_address, + created_at=datetime.now(), + ) + db.add(db_obj) + db.flush() # Get the order ID without committing transaction + + # Create order items + for item_data in order_items: + order_item = OrderItem( + order_id=db_obj.id, + product_id=item_data["product_id"], + quantity=item_data["quantity"], + unit_price=item_data["unit_price"], + total_price=item_data["total_price"] + ) + db.add(order_item) + + db.commit() + db.refresh(db_obj) + return db_obj + + +def update(db: Session, *, db_obj: Order, obj_in: Union[OrderUpdate, Dict[str, Any]]) -> Order: + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.dict(exclude_unset=True) + + # Don't allow changing order_number or created_at + update_data.pop("order_number", None) + update_data.pop("created_at", None) + + # Set updated_at + update_data["updated_at"] = datetime.now() + + for 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 update_status(db: Session, *, order_id: int, status: OrderStatus) -> Optional[Order]: + """Update order status""" + order = get(db, order_id) + if not order: + return None + + order.status = status + order.updated_at = datetime.now() + + db.add(order) + db.commit() + db.refresh(order) + return order + + +def cancel_order(db: Session, *, order_id: int) -> Optional[Order]: + """Cancel an order if it hasn't been shipped yet""" + order = get(db, order_id) + if not order: + return None + + if order.status in [OrderStatus.SHIPPED, OrderStatus.DELIVERED]: + raise ValueError("Cannot cancel an order that has already been shipped or delivered") + + order.status = OrderStatus.CANCELLED + order.updated_at = datetime.now() + + db.add(order) + db.commit() + db.refresh(order) + return order + + +def get_order_items(db: Session, *, order_id: int) -> List[OrderItem]: + """Get all items for a specific order""" + return db.query(OrderItem).filter(OrderItem.order_id == order_id).all() \ No newline at end of file diff --git a/app/services/product.py b/app/services/product.py new file mode 100644 index 0000000..09d9c60 --- /dev/null +++ b/app/services/product.py @@ -0,0 +1,61 @@ +from typing import Any, Dict, List, Optional, Union + +from sqlalchemy.orm import Session + +from app.models.product import Product +from app.schemas.product import ProductCreate, ProductUpdate + + +def get(db: Session, product_id: int) -> Optional[Product]: + return db.query(Product).filter(Product.id == product_id).first() + + +def get_by_sku(db: Session, sku: str) -> Optional[Product]: + return db.query(Product).filter(Product.sku == sku).first() + + +def get_multi( + db: Session, *, skip: int = 0, limit: int = 100, active_only: bool = False +) -> List[Product]: + query = db.query(Product) + if active_only: + query = query.filter(Product.is_active == True) + return query.offset(skip).limit(limit).all() + + +def create(db: Session, *, obj_in: ProductCreate) -> Product: + db_obj = Product( + name=obj_in.name, + sku=obj_in.sku, + description=obj_in.description, + weight=obj_in.weight, + dimensions=obj_in.dimensions, + price=obj_in.price, + is_active=obj_in.is_active, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def update( + db: Session, *, db_obj: Product, obj_in: Union[ProductUpdate, Dict[str, Any]] +) -> Product: + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.dict(exclude_unset=True) + for field in update_data: + setattr(db_obj, field, update_data[field]) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def remove(db: Session, *, product_id: int) -> Product: + obj = db.query(Product).get(product_id) + db.delete(obj) + db.commit() + return obj \ No newline at end of file diff --git a/app/services/shipment.py b/app/services/shipment.py new file mode 100644 index 0000000..02a32b9 --- /dev/null +++ b/app/services/shipment.py @@ -0,0 +1,174 @@ +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional, Union + +from sqlalchemy.orm import Session + +from app.models.inventory import Inventory +from app.models.order import Order, OrderStatus +from app.models.shipment import Shipment, ShipmentItem, ShipmentStatus +from app.schemas.shipment import ShipmentCreate, ShipmentUpdate +from app.services import inventory as inventory_service + + +def generate_tracking_number() -> str: + """Generate a unique tracking number""" + # Format: TRK-{random_uuid} + return f"TRK-{uuid.uuid4().hex[:12].upper()}" + + +def get(db: Session, shipment_id: int) -> Optional[Shipment]: + return db.query(Shipment).filter(Shipment.id == shipment_id).first() + + +def get_by_tracking_number(db: Session, tracking_number: str) -> Optional[Shipment]: + return db.query(Shipment).filter(Shipment.tracking_number == tracking_number).first() + + +def get_multi( + db: Session, *, skip: int = 0, limit: int = 100, order_id: Optional[int] = None +) -> List[Shipment]: + query = db.query(Shipment) + if order_id: + query = query.filter(Shipment.order_id == order_id) + return query.order_by(Shipment.created_at.desc()).offset(skip).limit(limit).all() + + +def create(db: Session, *, obj_in: ShipmentCreate) -> Shipment: + # Validate shipment data + if not obj_in.destination_warehouse_id and not obj_in.customer_address: + raise ValueError("Either destination_warehouse_id or customer_address must be provided") + + # Create shipment + db_obj = Shipment( + tracking_number=generate_tracking_number(), + order_id=obj_in.order_id, + origin_warehouse_id=obj_in.origin_warehouse_id, + destination_warehouse_id=obj_in.destination_warehouse_id, + customer_address=obj_in.customer_address, + status=obj_in.status or ShipmentStatus.PENDING, + carrier=obj_in.carrier, + estimated_delivery=obj_in.estimated_delivery, + shipping_cost=obj_in.shipping_cost or 0.0, + created_by_id=obj_in.created_by_id, + created_at=datetime.now(), + ) + db.add(db_obj) + db.flush() # Get the shipment ID without committing transaction + + # Create shipment items and update inventory + for item in obj_in.items: + shipment_item = ShipmentItem( + shipment_id=db_obj.id, + product_id=item.product_id, + quantity=item.quantity + ) + db.add(shipment_item) + + # Reduce inventory at origin warehouse + inventory_item = inventory_service.get_by_product_and_warehouse( + db, product_id=item.product_id, warehouse_id=obj_in.origin_warehouse_id + ) + if not inventory_item: + raise ValueError(f"Product {item.product_id} not available in warehouse {obj_in.origin_warehouse_id}") + if inventory_item.quantity < item.quantity: + raise ValueError(f"Insufficient quantity for product {item.product_id} in warehouse {obj_in.origin_warehouse_id}") + + inventory_item.quantity -= item.quantity + db.add(inventory_item) + + # If this is warehouse-to-warehouse transfer, increase inventory at destination + if obj_in.destination_warehouse_id: + dest_inventory = inventory_service.get_by_product_and_warehouse( + db, product_id=item.product_id, warehouse_id=obj_in.destination_warehouse_id + ) + if dest_inventory: + dest_inventory.quantity += item.quantity + db.add(dest_inventory) + else: + # Create new inventory record at destination + new_dest_inventory = Inventory( + product_id=item.product_id, + warehouse_id=obj_in.destination_warehouse_id, + quantity=item.quantity, + ) + db.add(new_dest_inventory) + + # If this shipment is related to an order, update order status + if obj_in.order_id: + order = db.query(Order).get(obj_in.order_id) + if order and order.status == OrderStatus.PROCESSING: + order.status = OrderStatus.SHIPPED + order.updated_at = datetime.now() + db.add(order) + + db.commit() + db.refresh(db_obj) + return db_obj + + +def update( + db: Session, *, db_obj: Shipment, obj_in: Union[ShipmentUpdate, Dict[str, Any]] +) -> Shipment: + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.dict(exclude_unset=True) + + # Don't allow changing tracking_number or created_at + update_data.pop("tracking_number", None) + update_data.pop("created_at", None) + + # Set updated_at + update_data["updated_at"] = datetime.now() + + # Check for status changes + current_status = db_obj.status + new_status = update_data.get("status") + + if new_status and new_status != current_status: + # Handle status change logic + if new_status == ShipmentStatus.DELIVERED: + # Set actual delivery time if not already set + if not update_data.get("actual_delivery"): + update_data["actual_delivery"] = datetime.now() + + # If this shipment is related to an order, update order status + if db_obj.order_id: + order = db.query(Order).get(db_obj.order_id) + if order and order.status != OrderStatus.DELIVERED: + order.status = OrderStatus.DELIVERED + order.updated_at = datetime.now() + db.add(order) + + # Update shipment fields + for 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 update_status( + db: Session, *, shipment_id: int, status: ShipmentStatus, actual_delivery: Optional[datetime] = None +) -> Optional[Shipment]: + """Update shipment status""" + shipment = get(db, shipment_id) + if not shipment: + return None + + update_data = {"status": status, "updated_at": datetime.now()} + + if status == ShipmentStatus.DELIVERED and not actual_delivery: + update_data["actual_delivery"] = datetime.now() + elif actual_delivery: + update_data["actual_delivery"] = actual_delivery + + return update(db, db_obj=shipment, obj_in=update_data) + + +def get_shipment_items(db: Session, *, shipment_id: int) -> List[ShipmentItem]: + """Get all items for a specific shipment""" + return db.query(ShipmentItem).filter(ShipmentItem.shipment_id == shipment_id).all() \ No newline at end of file diff --git a/app/services/user.py b/app/services/user.py new file mode 100644 index 0000000..b5a803b --- /dev/null +++ b/app/services/user.py @@ -0,0 +1,84 @@ +from typing import Any, Dict, List, Optional, Union + +from sqlalchemy.orm import Session + +from app.core.security import get_password_hash, verify_password +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate + + +def get(db: Session, user_id: int) -> Optional[User]: + return db.query(User).filter(User.id == user_id).first() + + +def get_by_email(db: Session, email: str) -> Optional[User]: + return db.query(User).filter(User.email == email).first() + + +def get_by_username(db: Session, username: str) -> Optional[User]: + return db.query(User).filter(User.username == username).first() + + +def get_multi(db: Session, *, skip: int = 0, limit: int = 100) -> List[User]: + return db.query(User).offset(skip).limit(limit).all() + + +def create(db: Session, *, obj_in: UserCreate) -> User: + db_obj = User( + email=obj_in.email, + username=obj_in.username, + hashed_password=get_password_hash(obj_in.password), + full_name=obj_in.full_name, + is_superuser=obj_in.is_superuser, + is_active=obj_in.is_active, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def update( + db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]] +) -> User: + 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 + for field in update_data: + if field not in ["hashed_password"]: # Only set attributes explicitly + setattr(db_obj, field, update_data[field]) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def remove(db: Session, *, user_id: int) -> User: + obj = db.query(User).get(user_id) + db.delete(obj) + db.commit() + return obj + + +def authenticate(db: Session, *, username_or_email: str, password: str) -> Optional[User]: + user = get_by_email(db, email=username_or_email) + if not user: + user = get_by_username(db, username=username_or_email) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user + + +def is_active(user: User) -> bool: + return user.is_active + + +def is_superuser(user: User) -> bool: + return user.is_superuser \ No newline at end of file diff --git a/app/services/warehouse.py b/app/services/warehouse.py new file mode 100644 index 0000000..ce79a33 --- /dev/null +++ b/app/services/warehouse.py @@ -0,0 +1,62 @@ +from typing import Any, Dict, List, Optional, Union + +from sqlalchemy.orm import Session + +from app.models.warehouse import Warehouse +from app.schemas.warehouse import WarehouseCreate, WarehouseUpdate + + +def get(db: Session, warehouse_id: int) -> Optional[Warehouse]: + return db.query(Warehouse).filter(Warehouse.id == warehouse_id).first() + + +def get_by_code(db: Session, code: str) -> Optional[Warehouse]: + return db.query(Warehouse).filter(Warehouse.code == code).first() + + +def get_multi( + db: Session, *, skip: int = 0, limit: int = 100, active_only: bool = False +) -> List[Warehouse]: + query = db.query(Warehouse) + if active_only: + query = query.filter(Warehouse.is_active == True) + return query.offset(skip).limit(limit).all() + + +def create(db: Session, *, obj_in: WarehouseCreate) -> Warehouse: + db_obj = Warehouse( + name=obj_in.name, + code=obj_in.code, + address=obj_in.address, + city=obj_in.city, + state=obj_in.state, + country=obj_in.country, + postal_code=obj_in.postal_code, + is_active=obj_in.is_active, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def update( + db: Session, *, db_obj: Warehouse, obj_in: Union[WarehouseUpdate, Dict[str, Any]] +) -> Warehouse: + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.dict(exclude_unset=True) + for field in update_data: + setattr(db_obj, field, update_data[field]) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + +def remove(db: Session, *, warehouse_id: int) -> Warehouse: + obj = db.query(Warehouse).get(warehouse_id) + db.delete(obj) + db.commit() + return obj \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..6b52b05 --- /dev/null +++ b/main.py @@ -0,0 +1,56 @@ +import uvicorn +from fastapi import Depends, FastAPI +from sqlalchemy import text +from sqlalchemy.orm import Session +from starlette.middleware.cors import CORSMiddleware + +from app.api.api import api_router +from app.core.config import settings +from app.db.session import get_db + +app = FastAPI( + title=settings.PROJECT_NAME, + openapi_url=f"{settings.API_V1_STR}/openapi.json", + description="Logistics 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.get("/health", tags=["Health"]) +async def health_check(db: Session = Depends(get_db)): + """ + Root health check endpoint to verify the application is running correctly + and database connection is working. + """ + health_data = { + "status": "ok", + "api": "healthy", + "database": "healthy" + } + + # Check database connectivity + try: + # Execute a simple query + db.execute(text("SELECT 1")) + except Exception as e: + health_data["database"] = "unhealthy" + health_data["status"] = "error" + health_data["database_error"] = str(e) + + return health_data + + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..5604729 --- /dev/null +++ b/migrations/README @@ -0,0 +1,26 @@ +# Alembic Migrations + +This directory contains database migration scripts managed by Alembic. + +## Structure + +- `alembic.ini`: Configuration file for Alembic +- `env.py`: Environment configuration for Alembic +- `script.py.mako`: Template for generating migration scripts +- `versions/`: Directory containing migration scripts + +## Usage + +To apply migrations: + +```bash +alembic upgrade head +``` + +To create a new migration (after changing models): + +```bash +alembic revision -m "description of changes" +``` + +For more information, see the [Alembic documentation](https://alembic.sqlalchemy.org/en/latest/). \ No newline at end of file diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..1b00133 --- /dev/null +++ b/migrations/__init__.py @@ -0,0 +1 @@ +# Alembic migrations package \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..c9491ad --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,84 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +# 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) + +# add your model's MetaData object here +# for 'autogenerate' support +from app.db.base import Base + +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + is_sqlite = connection.dialect.name == 'sqlite' + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=is_sqlite, # Key configuration for SQLite + # The following would ensure that foreign key constraints are enforced in SQLite + process_revision_directives=None, + # This configures how to reflect existing schema into models + compare_type=True, + compare_server_default=True, + ) + + 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..37d0cac --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/migrations/versions/001_initial_schema.py b/migrations/versions/001_initial_schema.py new file mode 100644 index 0000000..ced84bc --- /dev/null +++ b/migrations/versions/001_initial_schema.py @@ -0,0 +1,177 @@ +"""Initial schema + +Revision ID: 001 +Revises: +Create Date: 2023-09-10 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.sql import func + +# revision identifiers, used by Alembic. +revision = '001' +down_revision = None +branch_labels = None +depends_on = None + +# Enums +order_status_values = ['pending', 'processing', 'shipped', 'delivered', 'cancelled'] +shipment_status_values = ['pending', 'processing', 'in_transit', 'delivered', 'cancelled'] + + +def upgrade() -> None: + # Create users table + op.create_table( + 'users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('hashed_password', sa.String(), nullable=False), + sa.Column('full_name', sa.String(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True, default=True), + sa.Column('is_superuser', sa.Boolean(), nullable=True, default=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) + op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) + + # Create products table + op.create_table( + 'products', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('sku', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('weight', sa.Float(), nullable=False, default=0.0), + sa.Column('dimensions', sa.String(), nullable=True), + sa.Column('price', sa.Float(), nullable=False, default=0.0), + sa.Column('is_active', sa.Boolean(), nullable=True, default=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_products_id'), 'products', ['id'], unique=False) + op.create_index(op.f('ix_products_name'), 'products', ['name'], unique=False) + op.create_index(op.f('ix_products_sku'), 'products', ['sku'], unique=True) + + # Create warehouses table + op.create_table( + 'warehouses', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('code', sa.String(), nullable=False), + sa.Column('address', sa.Text(), nullable=False), + sa.Column('city', sa.String(), nullable=False), + sa.Column('state', sa.String(), nullable=False), + sa.Column('country', sa.String(), nullable=False), + sa.Column('postal_code', sa.String(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=True, default=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_warehouses_id'), 'warehouses', ['id'], unique=False) + op.create_index(op.f('ix_warehouses_name'), 'warehouses', ['name'], unique=False) + op.create_index(op.f('ix_warehouses_code'), 'warehouses', ['code'], 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('warehouse_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False, default=0), + sa.Column('location', sa.String(), nullable=True), + sa.Column('min_stock_level', sa.Integer(), nullable=True, default=0), + sa.Column('max_stock_level', sa.Integer(), nullable=True, default=0), + sa.ForeignKeyConstraint(['product_id'], ['products.id'], ), + sa.ForeignKeyConstraint(['warehouse_id'], ['warehouses.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('product_id', 'warehouse_id', name='uix_product_warehouse') + ) + op.create_index(op.f('ix_inventory_id'), 'inventory', ['id'], unique=False) + + # Create orders table + op.create_table( + 'orders', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('order_number', sa.String(), nullable=False), + sa.Column('status', sa.Enum(*order_status_values, name='orderstatus'), nullable=True, default='pending'), + sa.Column('total_amount', sa.Float(), nullable=False, default=0.0), + sa.Column('shipping_address', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=func.now(), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_orders_id'), 'orders', ['id'], unique=False) + op.create_index(op.f('ix_orders_order_number'), 'orders', ['order_number'], unique=True) + + # Create order_items table + op.create_table( + 'order_items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('order_id', sa.Integer(), nullable=False), + sa.Column('product_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False, default=1), + sa.Column('unit_price', sa.Float(), nullable=False), + sa.Column('total_price', sa.Float(), nullable=False), + sa.ForeignKeyConstraint(['order_id'], ['orders.id'], ), + sa.ForeignKeyConstraint(['product_id'], ['products.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_order_items_id'), 'order_items', ['id'], unique=False) + + # Create shipments table + op.create_table( + 'shipments', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('tracking_number', sa.String(), nullable=False), + sa.Column('order_id', sa.Integer(), nullable=True), + sa.Column('origin_warehouse_id', sa.Integer(), nullable=False), + sa.Column('destination_warehouse_id', sa.Integer(), nullable=True), + sa.Column('customer_address', sa.Text(), nullable=True), + sa.Column('status', sa.Enum(*shipment_status_values, name='shipmentstatus'), nullable=True, default='pending'), + sa.Column('carrier', sa.String(), nullable=True), + sa.Column('estimated_delivery', sa.DateTime(timezone=True), nullable=True), + sa.Column('actual_delivery', sa.DateTime(timezone=True), nullable=True), + sa.Column('shipping_cost', sa.Float(), nullable=True, default=0.0), + sa.Column('created_by_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=func.now(), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['destination_warehouse_id'], ['warehouses.id'], ), + sa.ForeignKeyConstraint(['order_id'], ['orders.id'], ), + sa.ForeignKeyConstraint(['origin_warehouse_id'], ['warehouses.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_shipments_id'), 'shipments', ['id'], unique=False) + op.create_index(op.f('ix_shipments_tracking_number'), 'shipments', ['tracking_number'], unique=True) + + # Create shipment_items table + op.create_table( + 'shipment_items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('shipment_id', sa.Integer(), nullable=False), + sa.Column('product_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False, default=1), + sa.ForeignKeyConstraint(['product_id'], ['products.id'], ), + sa.ForeignKeyConstraint(['shipment_id'], ['shipments.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_shipment_items_id'), 'shipment_items', ['id'], unique=False) + + +def downgrade() -> None: + # Drop all tables in reverse order of creation + op.drop_table('shipment_items') + op.drop_table('shipments') + op.drop_table('order_items') + op.drop_table('orders') + op.drop_table('inventory') + op.drop_table('warehouses') + op.drop_table('products') + op.drop_table('users') + + # Drop enums + op.execute('DROP TYPE IF EXISTS orderstatus') + op.execute('DROP TYPE IF EXISTS shipmentstatus') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e04cd81 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +fastapi>=0.103.1,<0.104.0 +uvicorn>=0.23.2,<0.24.0 +sqlalchemy>=2.0.20,<2.1.0 +alembic>=1.12.0,<1.13.0 +pydantic>=2.3.0,<2.4.0 +pydantic-settings>=2.0.3,<2.1.0 +python-multipart>=0.0.6,<0.1.0 +python-jose[cryptography]>=3.3.0,<3.4.0 +passlib[bcrypt]>=1.7.4,<1.8.0 +email-validator>=2.0.0,<2.1.0 +ruff>=0.0.290,<0.1.0 \ No newline at end of file