From 4aac37bc90e4f9f5495246ec1d02c3616763ca39 Mon Sep 17 00:00:00 2001 From: Automated Action Date: Thu, 5 Jun 2025 11:43:07 +0000 Subject: [PATCH] Implement inventory management system with FastAPI and SQLite - Setup project structure with FastAPI application - Create database models with SQLAlchemy - Configure Alembic for database migrations - Implement CRUD operations for products, categories, suppliers - Add inventory transaction functionality - Implement user authentication with JWT - Add health check endpoint - Create comprehensive documentation --- README.md | 171 +++++++++++++++++++- alembic.ini | 116 ++++++++++++++ alembic/__init__.py | 0 alembic/env.py | 86 +++++++++++ alembic/script.py.mako | 26 ++++ alembic/versions/initial_migration.py | 150 ++++++++++++++++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/deps.py | 58 +++++++ app/api/routes/__init__.py | 12 ++ app/api/routes/categories.py | 112 ++++++++++++++ app/api/routes/inventory.py | 113 ++++++++++++++ app/api/routes/login.py | 34 ++++ app/api/routes/products.py | 206 +++++++++++++++++++++++++ app/api/routes/suppliers.py | 112 ++++++++++++++ app/api/routes/users.py | 144 +++++++++++++++++ app/core/__init__.py | 0 app/core/config.py | 22 +++ app/core/security.py | 29 ++++ app/crud/__init__.py | 13 ++ app/crud/base.py | 62 ++++++++ app/crud/crud_category.py | 13 ++ app/crud/crud_inventory_transaction.py | 88 +++++++++++ app/crud/crud_product.py | 47 ++++++ app/crud/crud_supplier.py | 13 ++ app/crud/crud_user.py | 58 +++++++ app/db/base.py | 8 + app/db/base_class.py | 19 +++ app/db/session.py | 28 ++++ app/initial_data.py | 50 ++++++ app/models/__init__.py | 0 app/models/category.py | 16 ++ app/models/inventory_transaction.py | 31 ++++ app/models/product.py | 30 ++++ app/models/supplier.py | 19 +++ app/models/user.py | 20 +++ app/schemas/__init__.py | 19 +++ app/schemas/category.py | 33 ++++ app/schemas/inventory_transaction.py | 43 ++++++ app/schemas/product.py | 51 ++++++ app/schemas/supplier.py | 36 +++++ app/schemas/token.py | 11 ++ app/schemas/user.py | 42 +++++ app/utils/__init__.py | 0 main.py | 49 ++++++ requirements.txt | 14 ++ 46 files changed, 2202 insertions(+), 2 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/__init__.py create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/initial_migration.py create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/deps.py create mode 100644 app/api/routes/__init__.py create mode 100644 app/api/routes/categories.py create mode 100644 app/api/routes/inventory.py create mode 100644 app/api/routes/login.py create mode 100644 app/api/routes/products.py create mode 100644 app/api/routes/suppliers.py create mode 100644 app/api/routes/users.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/core/security.py create mode 100644 app/crud/__init__.py create mode 100644 app/crud/base.py create mode 100644 app/crud/crud_category.py create mode 100644 app/crud/crud_inventory_transaction.py create mode 100644 app/crud/crud_product.py create mode 100644 app/crud/crud_supplier.py create mode 100644 app/crud/crud_user.py create mode 100644 app/db/base.py create mode 100644 app/db/base_class.py create mode 100644 app/db/session.py create mode 100644 app/initial_data.py create mode 100644 app/models/__init__.py create mode 100644 app/models/category.py create mode 100644 app/models/inventory_transaction.py create mode 100644 app/models/product.py create mode 100644 app/models/supplier.py create mode 100644 app/models/user.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/category.py create mode 100644 app/schemas/inventory_transaction.py create mode 100644 app/schemas/product.py create mode 100644 app/schemas/supplier.py create mode 100644 app/schemas/token.py create mode 100644 app/schemas/user.py create mode 100644 app/utils/__init__.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/README.md b/README.md index e8acfba..b3fb2c9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,170 @@ -# FastAPI Application +# Small Business Inventory Management System -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A comprehensive inventory management system built with FastAPI and SQLite, designed for small businesses to track products, manage inventory transactions, suppliers, and categories. + +## Features + +- **Product Management**: Create, update, view, and delete products with details like SKU, barcode, pricing, and stock levels +- **Inventory Transactions**: Track inventory movements (purchases, sales, adjustments, returns) +- **Supplier Management**: Maintain supplier information and track products by supplier +- **Category Organization**: Organize products into categories +- **User Authentication**: Secure JWT-based authentication system +- **Role-Based Access Control**: Regular users and superusers with different permissions +- **API Documentation**: Auto-generated interactive documentation + +## Technology Stack + +- **Backend**: FastAPI (Python) +- **Database**: SQLite with SQLAlchemy ORM +- **Authentication**: JWT tokens +- **Migration System**: Alembic +- **Validation**: Pydantic + +## Project Structure + +``` +. +├── alembic/ # Database migration scripts +├── app/ # Main application +│ ├── api/ # API endpoints +│ │ ├── deps.py # Dependencies (auth, db session) +│ │ └── routes/ # API route modules +│ ├── core/ # Core modules (config, security) +│ ├── crud/ # CRUD operations +│ ├── db/ # Database setup and base classes +│ ├── models/ # SQLAlchemy models +│ └── schemas/ # Pydantic schemas for validation +├── storage/ # Storage directories +│ └── db/ # SQLite database location +├── main.py # Application entry point +├── alembic.ini # Alembic configuration +└── requirements.txt # Python dependencies +``` + +## Environment Variables + +The application uses the following environment variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| SECRET_KEY | JWT secret key for token generation | "generate_a_secure_secret_key_here" | +| ADMIN_PASSWORD | Password for initial admin user | "admin" | +| ACCESS_TOKEN_EXPIRE_MINUTES | JWT token expiration time in minutes | 11520 (8 days) | + +## Getting Started + +### Prerequisites + +- Python 3.8 or higher + +### Installation + +1. Clone the repository: + ```bash + git clone + cd smallbusinessinventorymanagementsystem + ``` + +2. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +3. Set up environment variables (recommended): + ```bash + export SECRET_KEY="your-secure-secret-key" + export ADMIN_PASSWORD="your-admin-password" + ``` + +4. Apply database migrations: + ```bash + alembic upgrade head + ``` + +5. Initialize the database with initial data (creates admin user): + ```bash + python -m app.initial_data + ``` + +6. Start the application: + ```bash + uvicorn main:app --host 0.0.0.0 --port 8000 --reload + ``` + +### API Documentation + +Once the application is running, you can access: + +- Interactive API documentation: http://localhost:8000/docs +- Alternative documentation: http://localhost:8000/redoc +- OpenAPI schema: http://localhost:8000/openapi.json + +## Authentication + +The API uses JWT tokens for authentication. To obtain a token: + +1. Make a POST request to `/api/v1/login/access-token` with form data: + - username: your username + - password: your password + +2. Use the returned access token in the Authorization header for protected endpoints: + ``` + Authorization: Bearer + ``` + +## Initial User + +An admin user is automatically created when running the initial_data script: + +- Username: admin +- Password: The value of ADMIN_PASSWORD env variable (default: "admin") +- Email: admin@example.com + +## API Endpoints + +### Authentication +- POST `/api/v1/login/access-token` - Get access token + +### Users +- GET `/api/v1/users/` - List users (admin only) +- POST `/api/v1/users/` - Create user (admin only) +- GET `/api/v1/users/me` - Get current user +- PUT `/api/v1/users/me` - Update current user +- GET `/api/v1/users/{user_id}` - Get user by ID +- PUT `/api/v1/users/{user_id}` - Update user (admin only) +- DELETE `/api/v1/users/{user_id}` - Delete user (admin only) + +### Products +- GET `/api/v1/products/` - List products +- POST `/api/v1/products/` - Create product +- GET `/api/v1/products/{product_id}` - Get product by ID +- PUT `/api/v1/products/{product_id}` - Update product +- DELETE `/api/v1/products/{product_id}` - Delete product +- POST `/api/v1/products/{product_id}/adjust` - Adjust product quantity + +### Categories +- GET `/api/v1/categories/` - List categories +- POST `/api/v1/categories/` - Create category +- GET `/api/v1/categories/{category_id}` - Get category by ID +- PUT `/api/v1/categories/{category_id}` - Update category +- DELETE `/api/v1/categories/{category_id}` - Delete category + +### Suppliers +- GET `/api/v1/suppliers/` - List suppliers +- POST `/api/v1/suppliers/` - Create supplier +- GET `/api/v1/suppliers/{supplier_id}` - Get supplier by ID +- PUT `/api/v1/suppliers/{supplier_id}` - Update supplier +- DELETE `/api/v1/suppliers/{supplier_id}` - Delete supplier + +### Inventory Transactions +- GET `/api/v1/inventory/transactions/` - List transactions +- POST `/api/v1/inventory/transactions/` - Create transaction +- GET `/api/v1/inventory/transactions/{transaction_id}` - Get transaction by ID +- DELETE `/api/v1/inventory/transactions/{transaction_id}` - Delete transaction (admin only) + +### Health Check +- GET `/health` - Check system health + +## License + +[MIT License](LICENSE) \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..00afd0a --- /dev/null +++ b/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/alembic/__init__.py b/alembic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..7017546 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,86 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context +import sys +from pathlib import Path + +# Add the parent directory to sys.path +sys.path.append(str(Path(__file__).parent.parent)) + +# Import the SQLAlchemy models +from app.db.base import Base # noqa + +# 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 +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, # Enable batch mode for SQLite + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + is_sqlite = connection.dialect.name == 'sqlite' + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=is_sqlite, # Enable batch mode for SQLite + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..3cf5352 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${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/alembic/versions/initial_migration.py b/alembic/versions/initial_migration.py new file mode 100644 index 0000000..8d232e2 --- /dev/null +++ b/alembic/versions/initial_migration.py @@ -0,0 +1,150 @@ +"""initial migration + +Revision ID: 01initial +Revises: +Create Date: 2023-11-12 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '01initial' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create the user table + op.create_table( + 'user', + 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), + sa.Column('is_superuser', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('username') + ) + op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True) + op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False) + op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True) + + # Create the category table + op.create_table( + 'category', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_category_id'), 'category', ['id'], unique=False) + op.create_index(op.f('ix_category_name'), 'category', ['name'], unique=False) + + # Create the supplier table + op.create_table( + 'supplier', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('contact_name', sa.String(), nullable=True), + sa.Column('email', sa.String(), nullable=True), + sa.Column('phone', sa.String(), nullable=True), + sa.Column('address', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_supplier_id'), 'supplier', ['id'], unique=False) + op.create_index(op.f('ix_supplier_name'), 'supplier', ['name'], unique=False) + + # Create the product table + op.create_table( + 'product', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('sku', sa.String(), nullable=False), + sa.Column('barcode', sa.String(), nullable=True), + sa.Column('quantity', sa.Integer(), nullable=True), + sa.Column('unit_price', sa.Float(), nullable=False), + sa.Column('cost_price', sa.Float(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('min_stock_level', sa.Integer(), nullable=True), + sa.Column('max_stock_level', sa.Integer(), nullable=True), + sa.Column('category_id', sa.Integer(), nullable=True), + sa.Column('supplier_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['category_id'], ['category.id'], ), + sa.ForeignKeyConstraint(['supplier_id'], ['supplier.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('barcode'), + sa.UniqueConstraint('sku') + ) + op.create_index(op.f('ix_product_barcode'), 'product', ['barcode'], unique=True) + op.create_index(op.f('ix_product_id'), 'product', ['id'], unique=False) + op.create_index(op.f('ix_product_name'), 'product', ['name'], unique=False) + op.create_index(op.f('ix_product_sku'), 'product', ['sku'], unique=True) + + # Define TransactionType enum for SQLAlchemy + transaction_type = sa.Enum('purchase', 'sale', 'adjustment', 'return', 'transfer', name='transactiontype') + transaction_type.create(op.get_bind()) + + # Create the inventory_transaction table + op.create_table( + 'inventory_transaction', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('transaction_type', transaction_type, nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.Column('unit_price', sa.Float(), nullable=True), + sa.Column('transaction_date', sa.DateTime(), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('reference_number', sa.String(), nullable=True), + sa.Column('product_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['product_id'], ['product.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_inventory_transaction_id'), 'inventory_transaction', ['id'], unique=False) + + +def downgrade() -> None: + # Drop tables in reverse order + op.drop_index(op.f('ix_inventory_transaction_id'), table_name='inventory_transaction') + op.drop_table('inventory_transaction') + + # Drop TransactionType enum + sa.Enum(name='transactiontype').drop(op.get_bind()) + + op.drop_index(op.f('ix_product_sku'), table_name='product') + op.drop_index(op.f('ix_product_name'), table_name='product') + op.drop_index(op.f('ix_product_id'), table_name='product') + op.drop_index(op.f('ix_product_barcode'), table_name='product') + op.drop_table('product') + + op.drop_index(op.f('ix_supplier_name'), table_name='supplier') + op.drop_index(op.f('ix_supplier_id'), table_name='supplier') + op.drop_table('supplier') + + op.drop_index(op.f('ix_category_name'), table_name='category') + op.drop_index(op.f('ix_category_id'), table_name='category') + op.drop_table('category') + + op.drop_index(op.f('ix_user_username'), table_name='user') + op.drop_index(op.f('ix_user_id'), table_name='user') + op.drop_index(op.f('ix_user_email'), table_name='user') + op.drop_table('user') \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..d3b0008 --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,58 @@ +from typing import Generator +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import jwt, JWTError +from pydantic import ValidationError +from sqlalchemy.orm import Session +from app import crud, models, schemas +from app.core.config import settings +from app.db.session import SessionLocal + +reusable_oauth2 = OAuth2PasswordBearer( + tokenUrl=f"{settings.API_V1_STR}/login/access-token" +) + + +def get_db() -> Generator: + try: + db = SessionLocal() + yield db + finally: + db.close() + + +def get_current_user( + db: Session = Depends(get_db), token: str = Depends(reusable_oauth2) +) -> models.User: + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=["HS256"] + ) + token_data = schemas.TokenPayload(**payload) + except (JWTError, ValidationError): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials", + ) + user = crud.user.get(db, id=token_data.sub) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +def get_current_active_user( + current_user: models.User = Depends(get_current_user), +) -> models.User: + if not crud.user.is_active(current_user): + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + + +def get_current_active_superuser( + current_user: models.User = Depends(get_current_user), +) -> models.User: + if not crud.user.is_superuser(current_user): + raise HTTPException( + status_code=400, detail="The user doesn't have enough privileges" + ) + return current_user \ No newline at end of file diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000..13ffe10 --- /dev/null +++ b/app/api/routes/__init__.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter +from app.core.config import settings +from app.api.routes import users, products, categories, suppliers, inventory, login + +api_router = APIRouter(prefix=settings.API_V1_STR) + +api_router.include_router(login.router, 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(categories.router, prefix="/categories", tags=["Categories"]) +api_router.include_router(suppliers.router, prefix="/suppliers", tags=["Suppliers"]) +api_router.include_router(inventory.router, prefix="/inventory", tags=["Inventory"]) \ No newline at end of file diff --git a/app/api/routes/categories.py b/app/api/routes/categories.py new file mode 100644 index 0000000..3a9cf4f --- /dev/null +++ b/app/api/routes/categories.py @@ -0,0 +1,112 @@ +from typing import Any, List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app import crud, models, schemas +from app.api import deps + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.Category]) +def read_categories( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve categories. + """ + categories = crud.category.get_multi(db, skip=skip, limit=limit) + return categories + + +@router.post("/", response_model=schemas.Category) +def create_category( + *, + db: Session = Depends(deps.get_db), + category_in: schemas.CategoryCreate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Create new category. + """ + # Check if category with the same name already exists + category = crud.category.get_by_name(db, name=category_in.name) + if category: + raise HTTPException( + status_code=400, + detail="The category with this name already exists in the system.", + ) + category = crud.category.create(db, obj_in=category_in) + return category + + +@router.get("/{category_id}", response_model=schemas.Category) +def read_category( + *, + db: Session = Depends(deps.get_db), + category_id: int, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get category by ID. + """ + category = crud.category.get(db, id=category_id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + return category + + +@router.put("/{category_id}", response_model=schemas.Category) +def update_category( + *, + db: Session = Depends(deps.get_db), + category_id: int, + category_in: schemas.CategoryUpdate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Update a category. + """ + category = crud.category.get(db, id=category_id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + + # If name is being updated, check for duplicates + if category_in.name and category_in.name != category.name: + existing_category = crud.category.get_by_name(db, name=category_in.name) + if existing_category: + raise HTTPException( + status_code=400, + detail="The category with this name already exists in the system.", + ) + + category = crud.category.update(db, db_obj=category, obj_in=category_in) + return category + + +@router.delete("/{category_id}", response_model=schemas.Category) +def delete_category( + *, + db: Session = Depends(deps.get_db), + category_id: int, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Delete a category. + """ + category = crud.category.get(db, id=category_id) + if not category: + raise HTTPException(status_code=404, detail="Category not found") + + # Check if any products are using this category + products = crud.product.get_by_category(db, category_id=category_id) + if products: + raise HTTPException( + status_code=400, + detail="Cannot delete category because it has associated products. Update or delete those products first.", + ) + + category = crud.category.remove(db, id=category_id) + return category \ No newline at end of file diff --git a/app/api/routes/inventory.py b/app/api/routes/inventory.py new file mode 100644 index 0000000..67b5ad6 --- /dev/null +++ b/app/api/routes/inventory.py @@ -0,0 +1,113 @@ +from typing import Any, List, Optional +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app import crud, models, schemas +from app.api import deps +from app.models.inventory_transaction import TransactionType + +router = APIRouter() + + +@router.get("/transactions/", response_model=List[schemas.InventoryTransaction]) +def read_inventory_transactions( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + product_id: Optional[int] = None, + user_id: Optional[int] = None, + transaction_type: Optional[TransactionType] = None, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve inventory transactions. + """ + if product_id: + transactions = crud.inventory_transaction.get_by_product( + db, product_id=product_id, skip=skip, limit=limit + ) + elif user_id: + if user_id != current_user.id and not crud.user.is_superuser(current_user): + raise HTTPException( + status_code=400, + detail="The user doesn't have enough privileges to access other users' transactions" + ) + transactions = crud.inventory_transaction.get_by_user( + db, user_id=user_id, skip=skip, limit=limit + ) + elif transaction_type: + transactions = crud.inventory_transaction.get_by_type( + db, transaction_type=transaction_type, skip=skip, limit=limit + ) + else: + transactions = crud.inventory_transaction.get_multi(db, skip=skip, limit=limit) + return transactions + + +@router.post("/transactions/", response_model=schemas.InventoryTransaction) +def create_inventory_transaction( + *, + db: Session = Depends(deps.get_db), + transaction_in: schemas.InventoryTransactionCreate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Create new inventory transaction. + """ + # Check if the product exists + product = crud.product.get(db, id=transaction_in.product_id) + if not product: + raise HTTPException( + status_code=404, + detail="The product does not exist in the system.", + ) + + # For sales, check if there's enough quantity + if (transaction_in.transaction_type == TransactionType.SALE and + transaction_in.quantity > product.quantity): + raise HTTPException( + status_code=400, + detail=f"Not enough inventory. Current stock: {product.quantity}", + ) + + # Create the transaction and update the product quantity + transaction = crud.inventory_transaction.create_with_product_update( + db, obj_in=transaction_in, current_user_id=current_user.id + ) + return transaction + + +@router.get("/transactions/{transaction_id}", response_model=schemas.InventoryTransaction) +def read_inventory_transaction( + *, + db: Session = Depends(deps.get_db), + transaction_id: int, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get inventory transaction by ID. + """ + transaction = crud.inventory_transaction.get(db, id=transaction_id) + if not transaction: + raise HTTPException(status_code=404, detail="Inventory transaction not found") + return transaction + + +@router.delete("/transactions/{transaction_id}", response_model=schemas.InventoryTransaction) +def delete_inventory_transaction( + *, + db: Session = Depends(deps.get_db), + transaction_id: int, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: + """ + Delete an inventory transaction (admin only). + """ + transaction = crud.inventory_transaction.get(db, id=transaction_id) + if not transaction: + raise HTTPException(status_code=404, detail="Inventory transaction not found") + + # This is a simplified approach - a proper implementation would include logic + # to reverse the product quantity change that occurred when the transaction was created + # For now, we'll just delete the transaction + transaction = crud.inventory_transaction.remove(db, id=transaction_id) + return transaction \ No newline at end of file diff --git a/app/api/routes/login.py b/app/api/routes/login.py new file mode 100644 index 0000000..0cffc1e --- /dev/null +++ b/app/api/routes/login.py @@ -0,0 +1,34 @@ +from datetime import timedelta +from typing import Any +from fastapi import APIRouter, Depends, HTTPException +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from app import crud, schemas +from app.api import deps +from app.core import security +from app.core.config import settings + +router = APIRouter() + + +@router.post("/login/access-token", response_model=schemas.Token) +def login_access_token( + db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends() +) -> Any: + """ + OAuth2 compatible token login, get an access token for future requests + """ + user = crud.user.authenticate( + db, username=form_data.username, password=form_data.password + ) + if not user: + raise HTTPException(status_code=400, detail="Incorrect email or password") + elif not crud.user.is_active(user): + raise HTTPException(status_code=400, detail="Inactive user") + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return { + "access_token": security.create_access_token( + user.id, expires_delta=access_token_expires + ), + "token_type": "bearer", + } \ No newline at end of file diff --git a/app/api/routes/products.py b/app/api/routes/products.py new file mode 100644 index 0000000..b1075e4 --- /dev/null +++ b/app/api/routes/products.py @@ -0,0 +1,206 @@ +from typing import Any, List, Optional +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app import crud, models, schemas +from app.api import deps + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.Product]) +def read_products( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + category_id: Optional[int] = None, + supplier_id: Optional[int] = None, + low_stock: bool = False, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve products. + """ + if category_id: + products = crud.product.get_by_category( + db, category_id=category_id, skip=skip, limit=limit + ) + elif supplier_id: + products = crud.product.get_by_supplier( + db, supplier_id=supplier_id, skip=skip, limit=limit + ) + elif low_stock: + products = crud.product.get_low_stock_products(db, skip=skip, limit=limit) + else: + products = crud.product.get_multi(db, skip=skip, limit=limit) + return products + + +@router.post("/", response_model=schemas.Product) +def create_product( + *, + db: Session = Depends(deps.get_db), + product_in: schemas.ProductCreate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Create new product. + """ + # Check if SKU already exists + product = crud.product.get_by_sku(db, sku=product_in.sku) + if product: + raise HTTPException( + status_code=400, + detail="The product with this SKU already exists in the system.", + ) + + # Check if barcode already exists if provided + if product_in.barcode: + product = crud.product.get_by_barcode(db, barcode=product_in.barcode) + if product: + raise HTTPException( + status_code=400, + detail="The product with this barcode already exists in the system.", + ) + + # Check if category exists if provided + if product_in.category_id: + category = crud.category.get(db, id=product_in.category_id) + if not category: + raise HTTPException( + status_code=404, + detail="The category does not exist in the system.", + ) + + # Check if supplier exists if provided + if product_in.supplier_id: + supplier = crud.supplier.get(db, id=product_in.supplier_id) + if not supplier: + raise HTTPException( + status_code=404, + detail="The supplier does not exist in the system.", + ) + + product = crud.product.create(db, obj_in=product_in) + return product + + +@router.get("/{product_id}", response_model=schemas.Product) +def read_product( + *, + db: Session = Depends(deps.get_db), + product_id: int, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get product by ID. + """ + product = crud.product.get(db, id=product_id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + return product + + +@router.put("/{product_id}", response_model=schemas.Product) +def update_product( + *, + db: Session = Depends(deps.get_db), + product_id: int, + product_in: schemas.ProductUpdate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Update a product. + """ + product = crud.product.get(db, id=product_id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + # If SKU is being updated, check for duplicates + if product_in.sku and product_in.sku != product.sku: + existing_product = crud.product.get_by_sku(db, sku=product_in.sku) + if existing_product: + raise HTTPException( + status_code=400, + detail="The product with this SKU already exists in the system.", + ) + + # If barcode is being updated, check for duplicates + if product_in.barcode and product_in.barcode != product.barcode: + existing_product = crud.product.get_by_barcode(db, barcode=product_in.barcode) + if existing_product: + raise HTTPException( + status_code=400, + detail="The product with this barcode already exists in the system.", + ) + + # Check if category exists if provided + if product_in.category_id: + category = crud.category.get(db, id=product_in.category_id) + if not category: + raise HTTPException( + status_code=404, + detail="The category does not exist in the system.", + ) + + # Check if supplier exists if provided + if product_in.supplier_id: + supplier = crud.supplier.get(db, id=product_in.supplier_id) + if not supplier: + raise HTTPException( + status_code=404, + detail="The supplier does not exist in the system.", + ) + + product = crud.product.update(db, db_obj=product, obj_in=product_in) + return product + + +@router.delete("/{product_id}", response_model=schemas.Product) +def delete_product( + *, + db: Session = Depends(deps.get_db), + product_id: int, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Delete a product. + """ + product = crud.product.get(db, id=product_id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + product = crud.product.remove(db, id=product_id) + return product + + +@router.post("/{product_id}/adjust", response_model=schemas.Product) +def adjust_product_quantity( + *, + db: Session = Depends(deps.get_db), + product_id: int, + adjustment: schemas.ProductAdjustment, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Adjust product quantity and create an inventory transaction. + """ + product = crud.product.get(db, id=product_id) + if not product: + raise HTTPException(status_code=404, detail="Product not found") + + # Create an inventory adjustment transaction + transaction_in = schemas.InventoryTransactionCreate( + transaction_type=models.inventory_transaction.TransactionType.ADJUSTMENT, + quantity=adjustment.quantity, + unit_price=product.unit_price, + notes=adjustment.notes, + product_id=product_id, + ) + + # Create the transaction and update the product quantity + crud.inventory_transaction.create_with_product_update( + db, obj_in=transaction_in, current_user_id=current_user.id + ) + + # Return the updated product + db.refresh(product) + return product \ No newline at end of file diff --git a/app/api/routes/suppliers.py b/app/api/routes/suppliers.py new file mode 100644 index 0000000..081e56a --- /dev/null +++ b/app/api/routes/suppliers.py @@ -0,0 +1,112 @@ +from typing import Any, List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app import crud, models, schemas +from app.api import deps + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.Supplier]) +def read_suppliers( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve suppliers. + """ + suppliers = crud.supplier.get_multi(db, skip=skip, limit=limit) + return suppliers + + +@router.post("/", response_model=schemas.Supplier) +def create_supplier( + *, + db: Session = Depends(deps.get_db), + supplier_in: schemas.SupplierCreate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Create new supplier. + """ + # Check if supplier with the same name already exists + supplier = crud.supplier.get_by_name(db, name=supplier_in.name) + if supplier: + raise HTTPException( + status_code=400, + detail="The supplier with this name already exists in the system.", + ) + supplier = crud.supplier.create(db, obj_in=supplier_in) + return supplier + + +@router.get("/{supplier_id}", response_model=schemas.Supplier) +def read_supplier( + *, + db: Session = Depends(deps.get_db), + supplier_id: int, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get supplier by ID. + """ + supplier = crud.supplier.get(db, id=supplier_id) + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + return supplier + + +@router.put("/{supplier_id}", response_model=schemas.Supplier) +def update_supplier( + *, + db: Session = Depends(deps.get_db), + supplier_id: int, + supplier_in: schemas.SupplierUpdate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Update a supplier. + """ + supplier = crud.supplier.get(db, id=supplier_id) + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + + # If name is being updated, check for duplicates + if supplier_in.name and supplier_in.name != supplier.name: + existing_supplier = crud.supplier.get_by_name(db, name=supplier_in.name) + if existing_supplier: + raise HTTPException( + status_code=400, + detail="The supplier with this name already exists in the system.", + ) + + supplier = crud.supplier.update(db, db_obj=supplier, obj_in=supplier_in) + return supplier + + +@router.delete("/{supplier_id}", response_model=schemas.Supplier) +def delete_supplier( + *, + db: Session = Depends(deps.get_db), + supplier_id: int, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Delete a supplier. + """ + supplier = crud.supplier.get(db, id=supplier_id) + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + + # Check if any products are using this supplier + products = crud.product.get_by_supplier(db, supplier_id=supplier_id) + if products: + raise HTTPException( + status_code=400, + detail="Cannot delete supplier because it has associated products. Update or delete those products first.", + ) + + supplier = crud.supplier.remove(db, id=supplier_id) + return supplier \ No newline at end of file diff --git a/app/api/routes/users.py b/app/api/routes/users.py new file mode 100644 index 0000000..a6baafc --- /dev/null +++ b/app/api/routes/users.py @@ -0,0 +1,144 @@ +from typing import Any, List +from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi.encoders import jsonable_encoder +from pydantic import EmailStr +from sqlalchemy.orm import Session +from app import crud, models, schemas +from app.api import deps + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.User]) +def read_users( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: + """ + Retrieve users. + """ + users = crud.user.get_multi(db, skip=skip, limit=limit) + return users + + +@router.post("/", response_model=schemas.User) +def create_user( + *, + db: Session = Depends(deps.get_db), + user_in: schemas.UserCreate, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: + """ + Create new user. + """ + user = crud.user.get_by_email(db, email=user_in.email) + if user: + raise HTTPException( + status_code=400, + detail="The user with this email already exists in the system.", + ) + user = crud.user.get_by_username(db, username=user_in.username) + if user: + raise HTTPException( + status_code=400, + detail="The user with this username already exists in the system.", + ) + user = crud.user.create(db, obj_in=user_in) + return user + + +@router.get("/me", response_model=schemas.User) +def read_user_me( + db: Session = Depends(deps.get_db), + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get current user. + """ + return current_user + + +@router.put("/me", response_model=schemas.User) +def update_user_me( + *, + db: Session = Depends(deps.get_db), + password: str = Body(None), + full_name: str = Body(None), + email: EmailStr = Body(None), + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Update own user. + """ + current_user_data = jsonable_encoder(current_user) + user_in = schemas.UserUpdate(**current_user_data) + if password is not None: + user_in.password = password + if full_name is not None: + user_in.full_name = full_name + if email is not None: + user_in.email = email + user = crud.user.update(db, db_obj=current_user, obj_in=user_in) + return user + + +@router.get("/{user_id}", response_model=schemas.User) +def read_user_by_id( + user_id: int, + current_user: models.User = Depends(deps.get_current_active_user), + db: Session = Depends(deps.get_db), +) -> Any: + """ + Get a specific user by id. + """ + user = crud.user.get(db, id=user_id) + if user == current_user: + return user + if not crud.user.is_superuser(current_user): + raise HTTPException( + status_code=400, detail="The user doesn't have enough privileges" + ) + return user + + +@router.put("/{user_id}", response_model=schemas.User) +def update_user( + *, + db: Session = Depends(deps.get_db), + user_id: int, + user_in: schemas.UserUpdate, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: + """ + Update a user. + """ + user = crud.user.get(db, id=user_id) + if not user: + raise HTTPException( + status_code=404, + detail="The user with this id does not exist in the system", + ) + user = crud.user.update(db, db_obj=user, obj_in=user_in) + return user + + +@router.delete("/{user_id}", response_model=schemas.User) +def delete_user( + *, + db: Session = Depends(deps.get_db), + user_id: int, + current_user: models.User = Depends(deps.get_current_active_superuser), +) -> Any: + """ + Delete a user. + """ + user = crud.user.get(db, id=user_id) + if not user: + raise HTTPException( + status_code=404, + detail="The user with this id does not exist in the system", + ) + user = crud.user.remove(db, id=user_id) + return user \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..193a240 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,22 @@ +from typing import List +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # Project metadata + PROJECT_NAME: str = "Small Business Inventory Management System" + API_V1_STR: str = "/api/v1" + + # Security + SECRET_KEY: str = "generate_a_secure_secret_key_here" # Used for JWT token creation + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days + + # CORS + BACKEND_CORS_ORIGINS: List[str] = ["*"] + + class Config: + env_file = ".env" + case_sensitive = True + + +settings = Settings() \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..cb440fc --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,29 @@ +from datetime import datetime, timedelta +from typing import Any, Union, Optional +from jose import jwt +from passlib.context import CryptContext +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def create_access_token( + subject: Union[str, Any], expires_delta: Optional[timedelta] = None +) -> str: + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256") + 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/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..cb4e569 --- /dev/null +++ b/app/crud/__init__.py @@ -0,0 +1,13 @@ +from app.crud.crud_user import user +from app.crud.crud_product import product +from app.crud.crud_category import category +from app.crud.crud_supplier import supplier +from app.crud.crud_inventory_transaction import inventory_transaction + +__all__ = [ + "user", + "product", + "category", + "supplier", + "inventory_transaction", +] \ No newline at end of file diff --git a/app/crud/base.py b/app/crud/base.py new file mode 100644 index 0000000..4203cdd --- /dev/null +++ b/app/crud/base.py @@ -0,0 +1,62 @@ +from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel +from sqlalchemy.orm import Session +from app.db.base_class import Base + +ModelType = TypeVar("ModelType", bound=Base) +CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel) +UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel) + + +class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]): + def __init__(self, model: Type[ModelType]): + """ + CRUD object with default methods to Create, Read, Update, Delete (CRUD). + **Parameters** + * `model`: A SQLAlchemy model class + * `schema`: A Pydantic model (schema) class + """ + self.model = model + + def get(self, db: Session, id: Any) -> Optional[ModelType]: + return db.query(self.model).filter(self.model.id == id).first() + + def get_multi( + self, db: Session, *, skip: int = 0, limit: int = 100 + ) -> List[ModelType]: + return db.query(self.model).offset(skip).limit(limit).all() + + def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType: + obj_in_data = jsonable_encoder(obj_in) + db_obj = self.model(**obj_in_data) # type: ignore + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def update( + self, + db: Session, + *, + db_obj: ModelType, + obj_in: Union[UpdateSchemaType, Dict[str, Any]] + ) -> ModelType: + obj_data = jsonable_encoder(db_obj) + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + for field in obj_data: + if field in update_data: + setattr(db_obj, field, update_data[field]) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def remove(self, db: Session, *, id: int) -> ModelType: + obj = db.query(self.model).get(id) + db.delete(obj) + db.commit() + return obj \ No newline at end of file diff --git a/app/crud/crud_category.py b/app/crud/crud_category.py new file mode 100644 index 0000000..cc8d4b0 --- /dev/null +++ b/app/crud/crud_category.py @@ -0,0 +1,13 @@ +from typing import Optional +from sqlalchemy.orm import Session +from app.crud.base import CRUDBase +from app.models.category import Category +from app.schemas.category import CategoryCreate, CategoryUpdate + + +class CRUDCategory(CRUDBase[Category, CategoryCreate, CategoryUpdate]): + def get_by_name(self, db: Session, *, name: str) -> Optional[Category]: + return db.query(Category).filter(Category.name == name).first() + + +category = CRUDCategory(Category) \ No newline at end of file diff --git a/app/crud/crud_inventory_transaction.py b/app/crud/crud_inventory_transaction.py new file mode 100644 index 0000000..6fd461c --- /dev/null +++ b/app/crud/crud_inventory_transaction.py @@ -0,0 +1,88 @@ +from typing import List +from sqlalchemy.orm import Session +from app.crud.base import CRUDBase +from app.models.inventory_transaction import InventoryTransaction, TransactionType +from app.schemas.inventory_transaction import InventoryTransactionCreate, InventoryTransactionUpdate +from datetime import datetime + + +class CRUDInventoryTransaction(CRUDBase[InventoryTransaction, InventoryTransactionCreate, InventoryTransactionUpdate]): + def create_with_product_update( + self, db: Session, *, obj_in: InventoryTransactionCreate, current_user_id: int + ) -> InventoryTransaction: + # Create transaction with the current user + db_obj = InventoryTransaction( + transaction_type=obj_in.transaction_type, + quantity=obj_in.quantity, + unit_price=obj_in.unit_price, + transaction_date=obj_in.transaction_date or datetime.utcnow(), + notes=obj_in.notes, + reference_number=obj_in.reference_number, + product_id=obj_in.product_id, + user_id=current_user_id, + ) + db.add(db_obj) + + # Update product quantity + product = db.query(self.model).filter( + self.model.product_id == obj_in.product_id + ).first().product + + # Update quantity based on transaction type + if obj_in.transaction_type == TransactionType.PURCHASE: + product.quantity += obj_in.quantity + elif obj_in.transaction_type == TransactionType.SALE: + product.quantity -= obj_in.quantity + elif obj_in.transaction_type == TransactionType.ADJUSTMENT: + product.quantity += obj_in.quantity # Can be positive or negative + elif obj_in.transaction_type == TransactionType.RETURN: + if obj_in.notes and 'purchase' in obj_in.notes.lower(): + # Return to supplier decreases quantity + product.quantity -= obj_in.quantity + else: + # Customer return increases quantity + product.quantity += obj_in.quantity + + db.add(product) + db.commit() + db.refresh(db_obj) + return db_obj + + def get_by_product( + self, db: Session, *, product_id: int, skip: int = 0, limit: int = 100 + ) -> List[InventoryTransaction]: + return ( + db.query(InventoryTransaction) + .filter(InventoryTransaction.product_id == product_id) + .order_by(InventoryTransaction.transaction_date.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + def get_by_user( + self, db: Session, *, user_id: int, skip: int = 0, limit: int = 100 + ) -> List[InventoryTransaction]: + return ( + db.query(InventoryTransaction) + .filter(InventoryTransaction.user_id == user_id) + .order_by(InventoryTransaction.transaction_date.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + def get_by_type( + self, db: Session, *, transaction_type: TransactionType, skip: int = 0, limit: int = 100 + ) -> List[InventoryTransaction]: + return ( + db.query(InventoryTransaction) + .filter(InventoryTransaction.transaction_type == transaction_type) + .order_by(InventoryTransaction.transaction_date.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + +inventory_transaction = CRUDInventoryTransaction(InventoryTransaction) \ No newline at end of file diff --git a/app/crud/crud_product.py b/app/crud/crud_product.py new file mode 100644 index 0000000..626c672 --- /dev/null +++ b/app/crud/crud_product.py @@ -0,0 +1,47 @@ +from typing import List, Optional +from sqlalchemy.orm import Session +from app.crud.base import CRUDBase +from app.models.product import Product +from app.schemas.product import ProductCreate, ProductUpdate + + +class CRUDProduct(CRUDBase[Product, ProductCreate, ProductUpdate]): + def get_by_sku(self, db: Session, *, sku: str) -> Optional[Product]: + return db.query(Product).filter(Product.sku == sku).first() + + def get_by_barcode(self, db: Session, *, barcode: str) -> Optional[Product]: + return db.query(Product).filter(Product.barcode == barcode).first() + + def get_by_category( + self, db: Session, *, category_id: int, skip: int = 0, limit: int = 100 + ) -> List[Product]: + return ( + db.query(Product) + .filter(Product.category_id == category_id) + .offset(skip) + .limit(limit) + .all() + ) + + def get_by_supplier( + self, db: Session, *, supplier_id: int, skip: int = 0, limit: int = 100 + ) -> List[Product]: + return ( + db.query(Product) + .filter(Product.supplier_id == supplier_id) + .offset(skip) + .limit(limit) + .all() + ) + + def get_low_stock_products(self, db: Session, *, skip: int = 0, limit: int = 100) -> List[Product]: + return ( + db.query(Product) + .filter(Product.quantity <= Product.min_stock_level) + .offset(skip) + .limit(limit) + .all() + ) + + +product = CRUDProduct(Product) \ No newline at end of file diff --git a/app/crud/crud_supplier.py b/app/crud/crud_supplier.py new file mode 100644 index 0000000..3a1de2d --- /dev/null +++ b/app/crud/crud_supplier.py @@ -0,0 +1,13 @@ +from typing import Optional +from sqlalchemy.orm import Session +from app.crud.base import CRUDBase +from app.models.supplier import Supplier +from app.schemas.supplier import SupplierCreate, SupplierUpdate + + +class CRUDSupplier(CRUDBase[Supplier, SupplierCreate, SupplierUpdate]): + def get_by_name(self, db: Session, *, name: str) -> Optional[Supplier]: + return db.query(Supplier).filter(Supplier.name == name).first() + + +supplier = CRUDSupplier(Supplier) \ No newline at end of file diff --git a/app/crud/crud_user.py b/app/crud/crud_user.py new file mode 100644 index 0000000..d58396a --- /dev/null +++ b/app/crud/crud_user.py @@ -0,0 +1,58 @@ +from typing import Any, Dict, Optional, Union +from sqlalchemy.orm import Session +from app.core.security import get_password_hash, verify_password +from app.crud.base import CRUDBase +from app.models.user import User +from app.schemas.user import UserCreate, UserUpdate + + +class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]): + def get_by_email(self, db: Session, *, email: str) -> Optional[User]: + return db.query(User).filter(User.email == email).first() + + def get_by_username(self, db: Session, *, username: str) -> Optional[User]: + return db.query(User).filter(User.username == username).first() + + def create(self, 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_active=obj_in.is_active, + is_superuser=False, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def update( + self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]] + ) -> User: + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.model_dump(exclude_unset=True) + if update_data.get("password"): + hashed_password = get_password_hash(update_data["password"]) + del update_data["password"] + update_data["hashed_password"] = hashed_password + return super().update(db, db_obj=db_obj, obj_in=update_data) + + def authenticate(self, db: Session, *, username: str, password: str) -> Optional[User]: + user = self.get_by_username(db, username=username) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user + + def is_active(self, user: User) -> bool: + return user.is_active + + def is_superuser(self, user: User) -> bool: + return user.is_superuser + + +user = CRUDUser(User) \ No newline at end of file diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..b2d3b99 --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,8 @@ +# Import all the models, so that Base has them before being +# imported by Alembic +from app.db.session import Base # noqa +from app.models.user import User # noqa +from app.models.product import Product # noqa +from app.models.category import Category # noqa +from app.models.supplier import Supplier # noqa +from app.models.inventory_transaction import InventoryTransaction # noqa \ No newline at end of file diff --git a/app/db/base_class.py b/app/db/base_class.py new file mode 100644 index 0000000..0a0f827 --- /dev/null +++ b/app/db/base_class.py @@ -0,0 +1,19 @@ +from typing import Any +from datetime import datetime +from sqlalchemy import Column, DateTime +from sqlalchemy.ext.declarative import as_declarative, declared_attr + + +@as_declarative() +class Base: + id: Any + __name__: str + + # Generate __tablename__ automatically + @declared_attr + def __tablename__(cls) -> str: + return cls.__name__.lower() + + # Common columns for all models + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..595794e --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,28 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from pathlib import Path + +# Create directory for database if it doesn't exist +DB_DIR = Path("/app") / "storage" / "db" +DB_DIR.mkdir(parents=True, exist_ok=True) + +SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +# Dependency +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/app/initial_data.py b/app/initial_data.py new file mode 100644 index 0000000..07810eb --- /dev/null +++ b/app/initial_data.py @@ -0,0 +1,50 @@ +import logging +from app.db.session import SessionLocal +from app.db.base import Base +from app.db.session import engine +from app.crud import user +from app.schemas.user import UserCreate +import os + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def init_db() -> None: + db = SessionLocal() + try: + # Create tables if they don't exist + Base.metadata.create_all(bind=engine) + + # Check if we already have users + user_obj = user.get_by_email(db, email="admin@example.com") + if not user_obj: + user_in = UserCreate( + email="admin@example.com", + username="admin", + password=os.environ.get("ADMIN_PASSWORD", "admin"), + full_name="System Administrator", + is_active=True, + ) + user_obj = user.create(db, obj_in=user_in) + logger.info(f"Created initial admin user: {user_obj.email}") + + # Make the admin a superuser + user_obj.is_superuser = True + db.add(user_obj) + db.commit() + logger.info(f"Made {user_obj.email} a superuser") + else: + logger.info(f"Admin user {user_obj.email} already exists") + finally: + db.close() + + +def main() -> None: + logger.info("Creating initial data") + init_db() + logger.info("Initial data created") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/category.py b/app/models/category.py new file mode 100644 index 0000000..c957fd9 --- /dev/null +++ b/app/models/category.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, Integer, String, Text +from sqlalchemy.orm import relationship +from app.db.base_class import Base + + +class Category(Base): + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True, nullable=False) + description = Column(Text, nullable=True) + + # Relationships + products = relationship( + "Product", + back_populates="category", + cascade="all, delete-orphan" + ) \ No newline at end of file diff --git a/app/models/inventory_transaction.py b/app/models/inventory_transaction.py new file mode 100644 index 0000000..1817aef --- /dev/null +++ b/app/models/inventory_transaction.py @@ -0,0 +1,31 @@ +from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime, Text, Enum +from sqlalchemy.orm import relationship +from app.db.base_class import Base +import enum +from datetime import datetime + + +class TransactionType(str, enum.Enum): + PURCHASE = "purchase" + SALE = "sale" + ADJUSTMENT = "adjustment" + RETURN = "return" + TRANSFER = "transfer" + + +class InventoryTransaction(Base): + id = Column(Integer, primary_key=True, index=True) + transaction_type = Column(Enum(TransactionType), nullable=False) + quantity = Column(Integer, nullable=False) + unit_price = Column(Float, nullable=True) + transaction_date = Column(DateTime, default=datetime.utcnow) + notes = Column(Text, nullable=True) + reference_number = Column(String, nullable=True) + + # Foreign Keys + product_id = Column(Integer, ForeignKey("product.id"), nullable=False) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False) + + # Relationships + product = relationship("Product", back_populates="inventory_transactions") + user = relationship("User", back_populates="inventory_transactions") \ No newline at end of file diff --git a/app/models/product.py b/app/models/product.py new file mode 100644 index 0000000..8820412 --- /dev/null +++ b/app/models/product.py @@ -0,0 +1,30 @@ +from sqlalchemy import Column, Integer, String, Float, Text, ForeignKey, Boolean +from sqlalchemy.orm import relationship +from app.db.base_class import Base + + +class Product(Base): + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True, nullable=False) + description = Column(Text, nullable=True) + sku = Column(String, unique=True, index=True, nullable=False) + barcode = Column(String, unique=True, index=True, nullable=True) + quantity = Column(Integer, default=0) + unit_price = Column(Float, nullable=False) + cost_price = Column(Float, nullable=True) + is_active = Column(Boolean, default=True) + min_stock_level = Column(Integer, default=0) + max_stock_level = Column(Integer, default=1000) + + # Foreign Keys + category_id = Column(Integer, ForeignKey("category.id"), nullable=True) + supplier_id = Column(Integer, ForeignKey("supplier.id"), nullable=True) + + # Relationships + category = relationship("Category", back_populates="products") + supplier = relationship("Supplier", back_populates="products") + inventory_transactions = relationship( + "InventoryTransaction", + back_populates="product", + cascade="all, delete-orphan" + ) \ No newline at end of file diff --git a/app/models/supplier.py b/app/models/supplier.py new file mode 100644 index 0000000..21a1c34 --- /dev/null +++ b/app/models/supplier.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, Integer, String, Text +from sqlalchemy.orm import relationship +from app.db.base_class import Base + + +class Supplier(Base): + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True, nullable=False) + contact_name = Column(String, nullable=True) + email = Column(String, nullable=True) + phone = Column(String, nullable=True) + address = Column(Text, nullable=True) + + # Relationships + products = relationship( + "Product", + back_populates="supplier", + cascade="all, delete-orphan" + ) \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..32dea23 --- /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.base_class import Base + + +class User(Base): + 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, nullable=True) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + + # Relationships + inventory_transactions = relationship( + "InventoryTransaction", + back_populates="user", + cascade="all, delete-orphan" + ) \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..c56c081 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,19 @@ +from app.schemas.user import User, UserCreate, UserUpdate, UserInDB +from app.schemas.token import Token, TokenPayload +from app.schemas.category import Category, CategoryCreate, CategoryUpdate +from app.schemas.supplier import Supplier, SupplierCreate, SupplierUpdate +from app.schemas.product import ( + Product, ProductCreate, ProductUpdate, ProductAdjustment +) +from app.schemas.inventory_transaction import ( + InventoryTransaction, InventoryTransactionCreate, InventoryTransactionUpdate +) + +__all__ = [ + "User", "UserCreate", "UserUpdate", "UserInDB", + "Token", "TokenPayload", + "Category", "CategoryCreate", "CategoryUpdate", + "Supplier", "SupplierCreate", "SupplierUpdate", + "Product", "ProductCreate", "ProductUpdate", "ProductAdjustment", + "InventoryTransaction", "InventoryTransactionCreate", "InventoryTransactionUpdate", +] \ No newline at end of file diff --git a/app/schemas/category.py b/app/schemas/category.py new file mode 100644 index 0000000..781b595 --- /dev/null +++ b/app/schemas/category.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +# Shared properties +class CategoryBase(BaseModel): + name: str + description: Optional[str] = None + + +# Properties to receive via API on creation +class CategoryCreate(CategoryBase): + pass + + +# Properties to receive via API on update +class CategoryUpdate(CategoryBase): + name: Optional[str] = None + + +class CategoryInDBBase(CategoryBase): + id: int + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Additional properties to return via API +class Category(CategoryInDBBase): + pass \ No newline at end of file diff --git a/app/schemas/inventory_transaction.py b/app/schemas/inventory_transaction.py new file mode 100644 index 0000000..fa856d8 --- /dev/null +++ b/app/schemas/inventory_transaction.py @@ -0,0 +1,43 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime +from app.models.inventory_transaction import TransactionType + + +# Shared properties +class InventoryTransactionBase(BaseModel): + transaction_type: TransactionType + quantity: int + unit_price: Optional[float] = None + transaction_date: Optional[datetime] = None + notes: Optional[str] = None + reference_number: Optional[str] = None + product_id: int + user_id: Optional[int] = None + + +# Properties to receive via API on creation +class InventoryTransactionCreate(InventoryTransactionBase): + pass + + +# Properties to receive via API on update +class InventoryTransactionUpdate(InventoryTransactionBase): + transaction_type: Optional[TransactionType] = None + quantity: Optional[int] = None + product_id: Optional[int] = None + + +class InventoryTransactionInDBBase(InventoryTransactionBase): + id: int + user_id: int + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Additional properties to return via API +class InventoryTransaction(InventoryTransactionInDBBase): + 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..3ea8600 --- /dev/null +++ b/app/schemas/product.py @@ -0,0 +1,51 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime + + +# Shared properties +class ProductBase(BaseModel): + name: str + description: Optional[str] = None + sku: str + barcode: Optional[str] = None + quantity: Optional[int] = 0 + unit_price: float + cost_price: Optional[float] = None + is_active: Optional[bool] = True + min_stock_level: Optional[int] = 0 + max_stock_level: Optional[int] = 1000 + category_id: Optional[int] = None + supplier_id: Optional[int] = None + + +# Properties to receive via API on creation +class ProductCreate(ProductBase): + pass + + +# Properties to receive via API on update +class ProductUpdate(ProductBase): + name: Optional[str] = None + sku: Optional[str] = None + unit_price: Optional[float] = None + + +class ProductInDBBase(ProductBase): + id: int + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Additional properties to return via API +class Product(ProductInDBBase): + pass + + +# Schema for inventory adjustment +class ProductAdjustment(BaseModel): + quantity: int + notes: Optional[str] = None \ No newline at end of file diff --git a/app/schemas/supplier.py b/app/schemas/supplier.py new file mode 100644 index 0000000..fa1937a --- /dev/null +++ b/app/schemas/supplier.py @@ -0,0 +1,36 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional +from datetime import datetime + + +# Shared properties +class SupplierBase(BaseModel): + name: str + contact_name: Optional[str] = None + email: Optional[EmailStr] = None + phone: Optional[str] = None + address: Optional[str] = None + + +# Properties to receive via API on creation +class SupplierCreate(SupplierBase): + pass + + +# Properties to receive via API on update +class SupplierUpdate(SupplierBase): + name: Optional[str] = None + + +class SupplierInDBBase(SupplierBase): + id: int + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Additional properties to return via API +class Supplier(SupplierInDBBase): + pass \ No newline at end of file diff --git a/app/schemas/token.py b/app/schemas/token.py new file mode 100644 index 0000000..4b0e9c5 --- /dev/null +++ b/app/schemas/token.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel +from typing import Optional + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenPayload(BaseModel): + sub: Optional[int] = None \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..54b975e --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,42 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional +from datetime import datetime + + +# Shared properties +class UserBase(BaseModel): + email: Optional[EmailStr] = None + username: Optional[str] = None + is_active: Optional[bool] = True + 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 + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Additional properties to return via API +class User(UserInDBBase): + pass + + +# Additional properties stored in DB +class UserInDB(UserInDBBase): + hashed_password: str \ No newline at end of file diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..dcaa457 --- /dev/null +++ b/main.py @@ -0,0 +1,49 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.core.config import settings +from app.api.routes import api_router +from app.db.session import engine + +app = FastAPI( + title=settings.PROJECT_NAME, + description="Inventory Management System API for Small Businesses", + version="0.1.0", + openapi_url="/openapi.json", + docs_url="/docs", + redoc_url="/redoc", +) + +# Set up CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include API router +app.include_router(api_router) + +# Health check endpoint +@app.get("/health", tags=["Health"]) +async def health_check(): + # Try to connect to the database + try: + # Create a new connection and check if it works + with engine.connect() as connection: + result = connection.execute("SELECT 1").fetchone() + db_status = "healthy" if result[0] == 1 else "unhealthy" + except Exception: + db_status = "unhealthy" + + return { + "status": "healthy", + "database": db_status, + "api_version": "0.1.0", + "environment": "development" + } + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7112067 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +sqlalchemy==2.0.23 +alembic==1.12.1 +pydantic==2.4.2 +pydantic-settings==2.1.0 +python-jose==3.3.0 +passlib==1.7.4 +python-multipart==0.0.6 +email-validator==2.1.0.post1 +ruff==0.1.5 +httpx==0.25.1 +bcrypt==4.0.1 +python-dotenv==1.0.0 \ No newline at end of file