Implement retail management and payment API with FastAPI
This API provides endpoints for: - Product and inventory management - Customer management - Order processing - Payment processing with Stripe integration - User authentication generated with BackendIM... (backend.im)
This commit is contained in:
parent
16f8e726bf
commit
609e7fb237
118
README.md
118
README.md
@ -1,3 +1,117 @@
|
||||
# FastAPI Application
|
||||
# Retail Management and Payment API
|
||||
|
||||
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform.
|
||||
A FastAPI-based REST API for retail management including inventory, orders, sales, and payment processing with Stripe integration.
|
||||
|
||||
## Features
|
||||
|
||||
- **Product Management**: Create, read, update, and delete products
|
||||
- **Inventory Management**: Track and update product inventory
|
||||
- **Customer Management**: Manage customer information
|
||||
- **Order Management**: Process and track customer orders
|
||||
- **Payment Processing**: Process payments via Stripe integration
|
||||
- **Authentication**: JWT-based user authentication and authorization
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: FastAPI
|
||||
- **Database**: SQLite (with SQLAlchemy ORM)
|
||||
- **Migrations**: Alembic
|
||||
- **Authentication**: JWT (JSON Web Tokens)
|
||||
- **Payment Processing**: Stripe SDK
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `POST /api/v1/auth/login` - Get access token
|
||||
- `POST /api/v1/auth/register` - Register a new user
|
||||
- `GET /api/v1/auth/me` - Get current user info
|
||||
|
||||
### Products
|
||||
- `GET /api/v1/products` - List all products
|
||||
- `POST /api/v1/products` - Create a new product
|
||||
- `GET /api/v1/products/{product_id}` - Get a single product
|
||||
- `PUT /api/v1/products/{product_id}` - Update a product
|
||||
- `DELETE /api/v1/products/{product_id}` - Delete a product
|
||||
|
||||
### Inventory
|
||||
- `GET /api/v1/inventory` - List all inventory items
|
||||
- `POST /api/v1/inventory` - Create a new inventory record
|
||||
- `GET /api/v1/inventory/{product_id}` - Get inventory for a product
|
||||
- `PUT /api/v1/inventory/{inventory_id}` - Update an inventory record
|
||||
- `PUT /api/v1/inventory/{inventory_id}/adjust` - Adjust inventory quantity
|
||||
|
||||
### Customers
|
||||
- `GET /api/v1/customers` - List all customers
|
||||
- `POST /api/v1/customers` - Create a new customer
|
||||
- `GET /api/v1/customers/{customer_id}` - Get a customer
|
||||
- `PUT /api/v1/customers/{customer_id}` - Update a customer
|
||||
- `DELETE /api/v1/customers/{customer_id}` - Delete a customer
|
||||
|
||||
### Orders
|
||||
- `GET /api/v1/orders` - List all orders
|
||||
- `POST /api/v1/orders` - Create a new order
|
||||
- `GET /api/v1/orders/{order_id}` - Get an order
|
||||
- `PUT /api/v1/orders/{order_id}` - Update an order
|
||||
- `DELETE /api/v1/orders/{order_id}` - Cancel an order
|
||||
|
||||
### Payments
|
||||
- `POST /api/v1/payments/create-payment-intent/{order_id}` - Create a Stripe payment intent
|
||||
- `POST /api/v1/payments/webhook` - Handle Stripe webhook events
|
||||
|
||||
### Health Check
|
||||
- `GET /health` - API health status check
|
||||
|
||||
## Setup and Installation
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.8+
|
||||
- pip (Python package manager)
|
||||
|
||||
### Installation Steps
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone [repository_url]
|
||||
cd retailmanagementandpaymentapi
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. Set up environment variables (create a `.env` file in the project root):
|
||||
```
|
||||
JWT_SECRET_KEY=your-secret-key-change-in-production
|
||||
STRIPE_API_KEY=your-stripe-api-key
|
||||
STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret
|
||||
```
|
||||
|
||||
4. Run database migrations:
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
5. Start the development server:
|
||||
```bash
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
Once the server is running, you can access the auto-generated API documentation:
|
||||
|
||||
- Swagger UI: http://localhost:8000/docs
|
||||
- ReDoc: http://localhost:8000/redoc
|
||||
|
||||
## Testing the API
|
||||
|
||||
For testing the API endpoints, you can use the Swagger UI documentation or tools like Postman or curl.
|
||||
|
||||
### Stripe Testing
|
||||
|
||||
For testing Stripe integration:
|
||||
- Use Stripe's test API keys
|
||||
- Use Stripe's test card numbers for payment simulation:
|
||||
- Test successful payment: 4242 4242 4242 4242
|
||||
- Test failed payment: 4000 0000 0000 0002
|
85
alembic.ini
Normal file
85
alembic.ini
Normal file
@ -0,0 +1,85 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# 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 alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path
|
||||
# version_locations = %(here)s/bar %(here)s/bat alembic/versions
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[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
|
1
alembic/__init__.py
Normal file
1
alembic/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Alembic initialization
|
83
alembic/env.py
Normal file
83
alembic/env.py
Normal file
@ -0,0 +1,83 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
from app.db.base import Base # noqa
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# Import all models here
|
||||
# from app.models import user, product, inventory, customer, order # noqa
|
||||
|
||||
# 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.
|
||||
|
||||
from app.core.config import settings # noqa
|
||||
|
||||
config.set_main_option("sqlalchemy.url", settings.SQLALCHEMY_DATABASE_URL)
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
24
alembic/script.py.mako
Normal file
24
alembic/script.py.mako
Normal file
@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
152
alembic/versions/initial_migration.py
Normal file
152
alembic/versions/initial_migration.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""Initial migration
|
||||
|
||||
Revision ID: 00001
|
||||
Revises:
|
||||
Create Date: 2025-05-12
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '00001'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Create user table
|
||||
op.create_table(
|
||||
'user',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('email', sa.String(), nullable=True),
|
||||
sa.Column('username', sa.String(), nullable=True),
|
||||
sa.Column('hashed_password', sa.String(), nullable=True),
|
||||
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(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
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 product table
|
||||
op.create_table(
|
||||
'product',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('sku', sa.String(), nullable=True),
|
||||
sa.Column('price', sa.Float(), nullable=False),
|
||||
sa.Column('image_url', sa.String(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_product_id'), 'product', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_product_name'), 'product', ['name'], unique=False)
|
||||
op.create_index(op.f('ix_product_sku'), 'product', ['sku'], unique=True)
|
||||
|
||||
# Create inventory table
|
||||
op.create_table(
|
||||
'inventory',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('product_id', sa.Integer(), nullable=True),
|
||||
sa.Column('quantity', sa.Integer(), nullable=False),
|
||||
sa.Column('location', sa.String(), nullable=True),
|
||||
sa.Column('last_restock_date', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('product_id')
|
||||
)
|
||||
op.create_index(op.f('ix_inventory_id'), 'inventory', ['id'], unique=False)
|
||||
|
||||
# Create customer table
|
||||
op.create_table(
|
||||
'customer',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('first_name', sa.String(), nullable=False),
|
||||
sa.Column('last_name', sa.String(), nullable=False),
|
||||
sa.Column('email', sa.String(), nullable=True),
|
||||
sa.Column('phone', sa.String(), nullable=True),
|
||||
sa.Column('address', sa.String(), nullable=True),
|
||||
sa.Column('city', sa.String(), nullable=True),
|
||||
sa.Column('state', sa.String(), nullable=True),
|
||||
sa.Column('zip_code', sa.String(), nullable=True),
|
||||
sa.Column('country', sa.String(), nullable=True),
|
||||
sa.Column('stripe_customer_id', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_customer_email'), 'customer', ['email'], unique=True)
|
||||
op.create_index(op.f('ix_customer_id'), 'customer', ['id'], unique=False)
|
||||
|
||||
# Create order table
|
||||
op.create_table(
|
||||
'order',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('customer_id', sa.Integer(), nullable=True),
|
||||
sa.Column('order_number', sa.String(), nullable=True),
|
||||
sa.Column('status', sa.String(), nullable=True),
|
||||
sa.Column('total_amount', sa.Float(), nullable=True),
|
||||
sa.Column('payment_intent_id', sa.String(), nullable=True),
|
||||
sa.Column('payment_status', sa.String(), nullable=True),
|
||||
sa.Column('shipping_address', sa.String(), nullable=True),
|
||||
sa.Column('shipping_city', sa.String(), nullable=True),
|
||||
sa.Column('shipping_state', sa.String(), nullable=True),
|
||||
sa.Column('shipping_zip', sa.String(), nullable=True),
|
||||
sa.Column('shipping_country', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['customer_id'], ['customer.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_order_id'), 'order', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_order_order_number'), 'order', ['order_number'], unique=True)
|
||||
|
||||
# Create order_item table
|
||||
op.create_table(
|
||||
'orderitem',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('order_id', sa.Integer(), nullable=True),
|
||||
sa.Column('product_id', sa.Integer(), nullable=True),
|
||||
sa.Column('quantity', sa.Integer(), nullable=True),
|
||||
sa.Column('unit_price', sa.Float(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True),
|
||||
sa.ForeignKeyConstraint(['order_id'], ['order.id'], ),
|
||||
sa.ForeignKeyConstraint(['product_id'], ['product.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_orderitem_id'), 'orderitem', ['id'], unique=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index(op.f('ix_orderitem_id'), table_name='orderitem')
|
||||
op.drop_table('orderitem')
|
||||
op.drop_index(op.f('ix_order_order_number'), table_name='order')
|
||||
op.drop_index(op.f('ix_order_id'), table_name='order')
|
||||
op.drop_table('order')
|
||||
op.drop_index(op.f('ix_customer_id'), table_name='customer')
|
||||
op.drop_index(op.f('ix_customer_email'), table_name='customer')
|
||||
op.drop_table('customer')
|
||||
op.drop_index(op.f('ix_inventory_id'), table_name='inventory')
|
||||
op.drop_table('inventory')
|
||||
op.drop_index(op.f('ix_product_sku'), table_name='product')
|
||||
op.drop_index(op.f('ix_product_name'), table_name='product')
|
||||
op.drop_index(op.f('ix_product_id'), table_name='product')
|
||||
op.drop_table('product')
|
||||
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')
|
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# App initialization
|
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# API initialization
|
59
app/api/deps.py
Normal file
59
app/api/deps.py
Normal file
@ -0,0 +1,59 @@
|
||||
from typing import Generator, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import jwt
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import crud, models, schemas
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
|
||||
reusable_oauth2 = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
|
||||
|
||||
|
||||
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.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]
|
||||
)
|
||||
token_data = schemas.TokenPayload(**payload)
|
||||
except (jwt.JWTError, ValidationError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Could not validate credentials",
|
||||
)
|
||||
user = crud.user.get(db, id=token_data.sub)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
def get_current_active_user(
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
) -> models.User:
|
||||
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=status.HTTP_403_FORBIDDEN, detail="Not enough permissions"
|
||||
)
|
||||
return current_user
|
13
app/api/routes/__init__.py
Normal file
13
app/api/routes/__init__.py
Normal file
@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import auth, products, inventory, customers, orders, payments, health
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(auth.router, prefix="/api/v1/auth", tags=["authentication"])
|
||||
router.include_router(products.router, prefix="/api/v1/products", tags=["products"])
|
||||
router.include_router(inventory.router, prefix="/api/v1/inventory", tags=["inventory"])
|
||||
router.include_router(customers.router, prefix="/api/v1/customers", tags=["customers"])
|
||||
router.include_router(orders.router, prefix="/api/v1/orders", tags=["orders"])
|
||||
router.include_router(payments.router, prefix="/api/v1/payments", tags=["payments"])
|
||||
router.include_router(health.router, prefix="", tags=["health"])
|
71
app/api/routes/auth.py
Normal file
71
app/api/routes/auth.py
Normal file
@ -0,0 +1,71 @@
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import crud, models, schemas
|
||||
from app.api import deps
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=schemas.Token)
|
||||
def login_access_token(
|
||||
db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends()
|
||||
) -> Any:
|
||||
"""
|
||||
OAuth2 compatible token login, get an access token for future requests
|
||||
"""
|
||||
user = crud.user.authenticate(
|
||||
db, username=form_data.username, password=form_data.password
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Incorrect username or password")
|
||||
elif not crud.user.is_active(user):
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
access_token_expires = timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return {
|
||||
"access_token": security.create_access_token(
|
||||
user.id, expires_delta=access_token_expires
|
||||
),
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/register", response_model=schemas.User)
|
||||
def register_user(
|
||||
*,
|
||||
db: Session = Depends(deps.get_db),
|
||||
user_in: schemas.UserCreate,
|
||||
) -> Any:
|
||||
"""
|
||||
Register a new user
|
||||
"""
|
||||
user = crud.user.get_by_email(db, email=user_in.email)
|
||||
if user:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A user with this email already exists",
|
||||
)
|
||||
user = crud.user.get_by_username(db, username=user_in.username)
|
||||
if user:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A user with this username already exists",
|
||||
)
|
||||
user = crud.user.create(db, obj_in=user_in)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/me", response_model=schemas.User)
|
||||
def read_users_me(
|
||||
current_user: models.User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get current user
|
||||
"""
|
||||
return current_user
|
128
app/api/routes/customers.py
Normal file
128
app/api/routes/customers.py
Normal file
@ -0,0 +1,128 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import crud, models, schemas
|
||||
from app.api import deps
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[schemas.Customer])
|
||||
def list_customers(
|
||||
db: Session = Depends(deps.get_db),
|
||||
skip: int = Query(0, description="Skip first N items"),
|
||||
limit: int = Query(100, description="Limit the number of items returned"),
|
||||
current_user: models.User = Depends(deps.get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieve customers
|
||||
"""
|
||||
customers = crud.customer.get_multi(db, skip=skip, limit=limit)
|
||||
return customers
|
||||
|
||||
|
||||
@router.post("/", response_model=schemas.Customer)
|
||||
def create_customer(
|
||||
*,
|
||||
db: Session = Depends(deps.get_db),
|
||||
customer_in: schemas.CustomerCreate,
|
||||
current_user: models.User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Create new customer
|
||||
"""
|
||||
# Check if email already exists
|
||||
customer = crud.customer.get_by_email(db, email=customer_in.email)
|
||||
if customer:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A customer with this email already exists",
|
||||
)
|
||||
|
||||
# For now, we'll create the customer without a Stripe ID
|
||||
# In a real app, we'd create the Stripe customer first
|
||||
customer = crud.customer.create(db, obj_in=customer_in)
|
||||
return customer
|
||||
|
||||
|
||||
@router.get("/{customer_id}", response_model=schemas.Customer)
|
||||
def get_customer(
|
||||
*,
|
||||
db: Session = Depends(deps.get_db),
|
||||
customer_id: int = Path(..., description="The ID of the customer to get"),
|
||||
current_user: models.User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get a specific customer by id
|
||||
"""
|
||||
customer = crud.customer.get(db, id=customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
# Check if user is superuser or if they're accessing their own customer record
|
||||
if not crud.user.is_superuser(current_user):
|
||||
if not customer.user_id or customer.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not enough permissions to access this customer"
|
||||
)
|
||||
|
||||
return customer
|
||||
|
||||
|
||||
@router.put("/{customer_id}", response_model=schemas.Customer)
|
||||
def update_customer(
|
||||
*,
|
||||
db: Session = Depends(deps.get_db),
|
||||
customer_id: int = Path(..., description="The ID of the customer to update"),
|
||||
customer_in: schemas.CustomerUpdate,
|
||||
current_user: models.User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Update a customer
|
||||
"""
|
||||
customer = crud.customer.get(db, id=customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
# Check if user is superuser or if they're updating their own customer record
|
||||
if not crud.user.is_superuser(current_user):
|
||||
if not customer.user_id or customer.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not enough permissions to update this customer"
|
||||
)
|
||||
|
||||
# If email is being updated, check if it's already in use
|
||||
if customer_in.email and customer_in.email != customer.email:
|
||||
existing = crud.customer.get_by_email(db, email=customer_in.email)
|
||||
if existing and existing.id != customer_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A customer with this email already exists",
|
||||
)
|
||||
|
||||
customer = crud.customer.update(db, db_obj=customer, obj_in=customer_in)
|
||||
return customer
|
||||
|
||||
|
||||
@router.delete("/{customer_id}", response_model=schemas.Customer)
|
||||
def delete_customer(
|
||||
*,
|
||||
db: Session = Depends(deps.get_db),
|
||||
customer_id: int = Path(..., description="The ID of the customer to delete"),
|
||||
current_user: models.User = Depends(deps.get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
Delete a customer (admin only)
|
||||
"""
|
||||
customer = crud.customer.get(db, id=customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
# In a real app, we'd need to handle Stripe customer deletion and check for orders
|
||||
|
||||
customer = crud.customer.remove(db, id=customer_id)
|
||||
return customer
|
31
app/api/routes/health.py
Normal file
31
app/api/routes/health.py
Normal file
@ -0,0 +1,31 @@
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api import deps
|
||||
from app.db.session import SessionLocal
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health", response_model=Dict[str, Any])
|
||||
def health_check(db: Session = Depends(deps.get_db)) -> Any:
|
||||
"""
|
||||
Check API health status
|
||||
"""
|
||||
# Check database connection
|
||||
db_status = "healthy"
|
||||
try:
|
||||
# Execute a simple query
|
||||
db.execute("SELECT 1")
|
||||
except Exception as e:
|
||||
db_status = f"unhealthy: {str(e)}"
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"components": {
|
||||
"database": db_status
|
||||
},
|
||||
"version": "0.1.0"
|
||||
}
|
126
app/api/routes/inventory.py
Normal file
126
app/api/routes/inventory.py
Normal file
@ -0,0 +1,126 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import crud, models, schemas
|
||||
from app.api import deps
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[schemas.Inventory])
|
||||
def list_inventory(
|
||||
db: Session = Depends(deps.get_db),
|
||||
skip: int = Query(0, description="Skip first N items"),
|
||||
limit: int = Query(100, description="Limit the number of items returned"),
|
||||
low_stock_only: bool = Query(False, description="Only return low stock items"),
|
||||
low_stock_threshold: int = Query(10, description="Threshold for low stock"),
|
||||
current_user: models.User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieve inventory items with optional filtering
|
||||
"""
|
||||
if low_stock_only:
|
||||
items = crud.inventory.get_low_stock(
|
||||
db, threshold=low_stock_threshold, skip=skip, limit=limit
|
||||
)
|
||||
else:
|
||||
items = crud.inventory.get_multi(db, skip=skip, limit=limit)
|
||||
return items
|
||||
|
||||
|
||||
@router.get("/{product_id}", response_model=schemas.Inventory)
|
||||
def get_product_inventory(
|
||||
*,
|
||||
db: Session = Depends(deps.get_db),
|
||||
product_id: int = Path(..., description="The ID of the product"),
|
||||
current_user: models.User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get inventory for a specific product
|
||||
"""
|
||||
# Verify product exists
|
||||
product = crud.product.get(db, id=product_id)
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
inventory = crud.inventory.get_by_product_id(db, product_id=product_id)
|
||||
if not inventory:
|
||||
raise HTTPException(status_code=404, detail="Inventory not found for this product")
|
||||
|
||||
return inventory
|
||||
|
||||
|
||||
@router.post("/", response_model=schemas.Inventory)
|
||||
def create_inventory(
|
||||
*,
|
||||
db: Session = Depends(deps.get_db),
|
||||
inventory_in: schemas.InventoryCreate,
|
||||
current_user: models.User = Depends(deps.get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
Create inventory record for a product
|
||||
"""
|
||||
# Verify product exists
|
||||
product = crud.product.get(db, id=inventory_in.product_id)
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
# Check if inventory already exists for this product
|
||||
existing = crud.inventory.get_by_product_id(db, product_id=inventory_in.product_id)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Inventory already exists for this product",
|
||||
)
|
||||
|
||||
inventory = crud.inventory.create(db, obj_in=inventory_in)
|
||||
return inventory
|
||||
|
||||
|
||||
@router.put("/{inventory_id}", response_model=schemas.Inventory)
|
||||
def update_inventory(
|
||||
*,
|
||||
db: Session = Depends(deps.get_db),
|
||||
inventory_id: int = Path(..., description="The ID of the inventory record to update"),
|
||||
inventory_in: schemas.InventoryUpdate,
|
||||
current_user: models.User = Depends(deps.get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
Update an inventory record
|
||||
"""
|
||||
inventory = crud.inventory.get(db, id=inventory_id)
|
||||
if not inventory:
|
||||
raise HTTPException(status_code=404, detail="Inventory not found")
|
||||
|
||||
inventory = crud.inventory.update(db, db_obj=inventory, obj_in=inventory_in)
|
||||
return inventory
|
||||
|
||||
|
||||
@router.put("/{inventory_id}/adjust", response_model=schemas.Inventory)
|
||||
def adjust_inventory(
|
||||
*,
|
||||
db: Session = Depends(deps.get_db),
|
||||
inventory_id: int = Path(..., description="The ID of the inventory record to adjust"),
|
||||
quantity_change: int = Body(..., description="Amount to adjust (positive to add, negative to subtract)"),
|
||||
current_user: models.User = Depends(deps.get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
Adjust inventory quantity
|
||||
"""
|
||||
inventory = crud.inventory.get(db, id=inventory_id)
|
||||
if not inventory:
|
||||
raise HTTPException(status_code=404, detail="Inventory not found")
|
||||
|
||||
# Check if adjustment would result in negative quantity
|
||||
if inventory.quantity + quantity_change < 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Adjustment would result in negative inventory",
|
||||
)
|
||||
|
||||
inventory = crud.inventory.update_quantity(
|
||||
db, inventory_id=inventory_id, quantity_change=quantity_change
|
||||
)
|
||||
return inventory
|
191
app/api/routes/orders.py
Normal file
191
app/api/routes/orders.py
Normal file
@ -0,0 +1,191 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import crud, models, schemas
|
||||
from app.api import deps
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[schemas.Order])
|
||||
def list_orders(
|
||||
db: Session = Depends(deps.get_db),
|
||||
skip: int = Query(0, description="Skip first N items"),
|
||||
limit: int = Query(100, description="Limit the number of items returned"),
|
||||
customer_id: int = Query(None, description="Filter by customer ID"),
|
||||
current_user: models.User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieve orders with optional customer filtering
|
||||
"""
|
||||
if customer_id:
|
||||
# Check permissions if not superuser
|
||||
if not crud.user.is_superuser(current_user):
|
||||
customer = crud.customer.get(db, id=customer_id)
|
||||
if not customer or not customer.user_id or customer.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not enough permissions to access these orders"
|
||||
)
|
||||
|
||||
orders = crud.order.get_by_customer(db, customer_id=customer_id, skip=skip, limit=limit)
|
||||
else:
|
||||
# Non-superusers can only see their own orders
|
||||
if not crud.user.is_superuser(current_user):
|
||||
# Find customer associated with current user
|
||||
customer = db.query(models.Customer).filter(models.Customer.user_id == current_user.id).first()
|
||||
if not customer:
|
||||
return []
|
||||
orders = crud.order.get_by_customer(db, customer_id=customer.id, skip=skip, limit=limit)
|
||||
else:
|
||||
orders = crud.order.get_multi(db, skip=skip, limit=limit)
|
||||
return orders
|
||||
|
||||
|
||||
@router.post("/", response_model=schemas.Order)
|
||||
def create_order(
|
||||
*,
|
||||
db: Session = Depends(deps.get_db),
|
||||
order_in: schemas.OrderCreate,
|
||||
current_user: models.User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Create a new order
|
||||
"""
|
||||
# Verify customer exists
|
||||
customer = crud.customer.get(db, id=order_in.customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
# Check permissions if not superuser
|
||||
if not crud.user.is_superuser(current_user):
|
||||
if not customer.user_id or customer.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not enough permissions to create order for this customer"
|
||||
)
|
||||
|
||||
# Verify products exist and have enough inventory
|
||||
for item in order_in.items:
|
||||
product = crud.product.get(db, id=item.product_id)
|
||||
if not product:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Product with ID {item.product_id} not found"
|
||||
)
|
||||
|
||||
# Check inventory
|
||||
inventory = crud.inventory.get_by_product_id(db, product_id=item.product_id)
|
||||
if not inventory or inventory.quantity < item.quantity:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Not enough inventory for product {product.name}"
|
||||
)
|
||||
|
||||
# Create the order
|
||||
order = crud.order.create_with_items(db, obj_in=order_in)
|
||||
|
||||
# Update inventory
|
||||
for item in order.items:
|
||||
inventory = crud.inventory.get_by_product_id(db, product_id=item.product_id)
|
||||
if inventory:
|
||||
crud.inventory.update_quantity(
|
||||
db, inventory_id=inventory.id, quantity_change=-item.quantity
|
||||
)
|
||||
|
||||
return order
|
||||
|
||||
|
||||
@router.get("/{order_id}", response_model=schemas.Order)
|
||||
def get_order(
|
||||
*,
|
||||
db: Session = Depends(deps.get_db),
|
||||
order_id: int = Path(..., description="The ID of the order to get"),
|
||||
current_user: models.User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Get a specific order by id
|
||||
"""
|
||||
order = crud.order.get_with_items(db, order_id=order_id)
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
# Check permissions if not superuser
|
||||
if not crud.user.is_superuser(current_user):
|
||||
customer = crud.customer.get(db, id=order.customer_id)
|
||||
if not customer or not customer.user_id or customer.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not enough permissions to access this order"
|
||||
)
|
||||
|
||||
return order
|
||||
|
||||
|
||||
@router.put("/{order_id}", response_model=schemas.Order)
|
||||
def update_order(
|
||||
*,
|
||||
db: Session = Depends(deps.get_db),
|
||||
order_id: int = Path(..., description="The ID of the order to update"),
|
||||
order_in: schemas.OrderUpdate,
|
||||
current_user: models.User = Depends(deps.get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
Update an order's details (admin only)
|
||||
"""
|
||||
order = crud.order.get(db, id=order_id)
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
order = crud.order.update(db, db_obj=order, obj_in=order_in)
|
||||
return order
|
||||
|
||||
|
||||
@router.delete("/{order_id}", response_model=schemas.Order)
|
||||
def cancel_order(
|
||||
*,
|
||||
db: Session = Depends(deps.get_db),
|
||||
order_id: int = Path(..., description="The ID of the order to cancel"),
|
||||
current_user: models.User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Cancel an order and restore inventory
|
||||
"""
|
||||
order = crud.order.get_with_items(db, order_id=order_id)
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
# Check permissions if not superuser
|
||||
if not crud.user.is_superuser(current_user):
|
||||
customer = crud.customer.get(db, id=order.customer_id)
|
||||
if not customer or not customer.user_id or customer.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not enough permissions to cancel this order"
|
||||
)
|
||||
|
||||
# Check if order can be canceled
|
||||
if order.status not in ["pending", "processing"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot cancel order with status {order.status}"
|
||||
)
|
||||
|
||||
# Update order status
|
||||
order = crud.order.update(
|
||||
db, db_obj=order, obj_in={"status": "cancelled", "payment_status": "refunded"}
|
||||
)
|
||||
|
||||
# Restore inventory
|
||||
for item in order.items:
|
||||
inventory = crud.inventory.get_by_product_id(db, product_id=item.product_id)
|
||||
if inventory:
|
||||
crud.inventory.update_quantity(
|
||||
db, inventory_id=inventory.id, quantity_change=item.quantity
|
||||
)
|
||||
|
||||
# TODO: Handle Stripe refund if payment was made
|
||||
|
||||
return order
|
152
app/api/routes/payments.py
Normal file
152
app/api/routes/payments.py
Normal file
@ -0,0 +1,152 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import stripe
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import crud, models, schemas
|
||||
from app.api import deps
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Initialize Stripe
|
||||
stripe.api_key = settings.STRIPE_API_KEY
|
||||
|
||||
|
||||
@router.post("/create-payment-intent/{order_id}", response_model=schemas.PaymentIntentResponse)
|
||||
async def create_payment_intent(
|
||||
*,
|
||||
db: Session = Depends(deps.get_db),
|
||||
order_id: int = Path(..., description="The ID of the order to create payment for"),
|
||||
current_user: models.User = Depends(deps.get_current_active_user),
|
||||
) -> Any:
|
||||
"""
|
||||
Create a Stripe payment intent for an order
|
||||
"""
|
||||
# Get the order
|
||||
order = crud.order.get(db, id=order_id)
|
||||
if not order:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
|
||||
# Check permissions if not superuser
|
||||
if not crud.user.is_superuser(current_user):
|
||||
customer = crud.customer.get(db, id=order.customer_id)
|
||||
if not customer or not customer.user_id or customer.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Not enough permissions to create payment for this order"
|
||||
)
|
||||
|
||||
# Check if payment is already made
|
||||
if order.payment_status == "paid":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Payment has already been processed for this order"
|
||||
)
|
||||
|
||||
# Get or create Stripe customer
|
||||
customer = crud.customer.get(db, id=order.customer_id)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
stripe_customer_id = customer.stripe_customer_id
|
||||
if not stripe_customer_id:
|
||||
# Create a new Stripe customer
|
||||
stripe_customer = stripe.Customer.create(
|
||||
email=customer.email,
|
||||
name=f"{customer.first_name} {customer.last_name}",
|
||||
metadata={"customer_id": customer.id}
|
||||
)
|
||||
stripe_customer_id = stripe_customer["id"]
|
||||
|
||||
# Update the customer record with Stripe ID
|
||||
crud.customer.update(db, db_obj=customer, obj_in={"stripe_customer_id": stripe_customer_id})
|
||||
|
||||
# Create a payment intent
|
||||
try:
|
||||
intent = stripe.PaymentIntent.create(
|
||||
amount=int(order.total_amount * 100), # Convert to cents
|
||||
currency="usd",
|
||||
customer=stripe_customer_id,
|
||||
metadata={
|
||||
"order_id": order.id,
|
||||
"order_number": order.order_number
|
||||
}
|
||||
)
|
||||
|
||||
# Update the order with the payment intent ID
|
||||
crud.order.update_payment_status(
|
||||
db, order_id=order.id, payment_intent_id=intent["id"], payment_status="unpaid"
|
||||
)
|
||||
|
||||
# Return the client secret
|
||||
return {
|
||||
"client_secret": intent["client_secret"],
|
||||
"payment_intent_id": intent["id"]
|
||||
}
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/webhook", status_code=200)
|
||||
async def stripe_webhook(request: Request, response: Response) -> Any:
|
||||
"""
|
||||
Handle Stripe webhook events
|
||||
"""
|
||||
# Get the webhook body
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get("stripe-signature")
|
||||
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
except ValueError:
|
||||
# Invalid payload
|
||||
response.status_code = 400
|
||||
return {"error": "Invalid payload"}
|
||||
except stripe.error.SignatureVerificationError:
|
||||
# Invalid signature
|
||||
response.status_code = 400
|
||||
return {"error": "Invalid signature"}
|
||||
|
||||
# Handle the event
|
||||
db = next(deps.get_db())
|
||||
|
||||
if event["type"] == "payment_intent.succeeded":
|
||||
payment_intent = event["data"]["object"]
|
||||
# Extract order ID from metadata
|
||||
order_id = payment_intent["metadata"].get("order_id")
|
||||
|
||||
if order_id:
|
||||
# Update order payment status
|
||||
order = crud.order.get(db, id=int(order_id))
|
||||
if order:
|
||||
crud.order.update_payment_status(
|
||||
db,
|
||||
order_id=order.id,
|
||||
payment_intent_id=payment_intent["id"],
|
||||
payment_status="paid"
|
||||
)
|
||||
|
||||
elif event["type"] == "payment_intent.payment_failed":
|
||||
payment_intent = event["data"]["object"]
|
||||
# Extract order ID from metadata
|
||||
order_id = payment_intent["metadata"].get("order_id")
|
||||
|
||||
if order_id:
|
||||
# Update order payment status
|
||||
order = crud.order.get(db, id=int(order_id))
|
||||
if order:
|
||||
crud.order.update(
|
||||
db,
|
||||
db_obj=order,
|
||||
obj_in={"status": "payment_failed"}
|
||||
)
|
||||
|
||||
# You can handle more events here as needed
|
||||
|
||||
return {"status": "success"}
|
125
app/api/routes/products.py
Normal file
125
app/api/routes/products.py
Normal file
@ -0,0 +1,125 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||
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 list_products(
|
||||
db: Session = Depends(deps.get_db),
|
||||
skip: int = Query(0, description="Skip first N items"),
|
||||
limit: int = Query(100, description="Limit the number of items returned"),
|
||||
active_only: bool = Query(False, description="Only return active products"),
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieve products with optional filtering
|
||||
"""
|
||||
if active_only:
|
||||
products = crud.product.get_active(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,
|
||||
inventory_quantity: int = Body(0, description="Initial inventory quantity"),
|
||||
current_user: models.User = Depends(deps.get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
Create new product with optional initial inventory
|
||||
"""
|
||||
product = crud.product.get_by_sku(db, sku=product_in.sku)
|
||||
if product:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A product with this SKU already exists",
|
||||
)
|
||||
product = crud.product.create_with_inventory(
|
||||
db, obj_in=product_in, inventory_quantity=inventory_quantity
|
||||
)
|
||||
return product
|
||||
|
||||
|
||||
@router.get("/{product_id}", response_model=schemas.ProductWithInventory)
|
||||
def get_product(
|
||||
*,
|
||||
db: Session = Depends(deps.get_db),
|
||||
product_id: int = Path(..., description="The ID of the product to get"),
|
||||
) -> Any:
|
||||
"""
|
||||
Get a specific product by id with inventory information
|
||||
"""
|
||||
product = crud.product.get(db, id=product_id)
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
# Get inventory information
|
||||
inventory = crud.inventory.get_by_product_id(db, product_id=product_id)
|
||||
inventory_quantity = inventory.quantity if inventory else 0
|
||||
|
||||
# Create combined response
|
||||
product_data = schemas.Product.from_orm(product)
|
||||
product_with_inventory = schemas.ProductWithInventory(
|
||||
**product_data.dict(),
|
||||
inventory_quantity=inventory_quantity
|
||||
)
|
||||
|
||||
return product_with_inventory
|
||||
|
||||
|
||||
@router.put("/{product_id}", response_model=schemas.Product)
|
||||
def update_product(
|
||||
*,
|
||||
db: Session = Depends(deps.get_db),
|
||||
product_id: int = Path(..., description="The ID of the product to update"),
|
||||
product_in: schemas.ProductUpdate,
|
||||
current_user: models.User = Depends(deps.get_current_active_superuser),
|
||||
) -> 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 uniqueness
|
||||
if product_in.sku and product_in.sku != product.sku:
|
||||
existing = crud.product.get_by_sku(db, sku=product_in.sku)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A product with this SKU already exists",
|
||||
)
|
||||
|
||||
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 = Path(..., description="The ID of the product to delete"),
|
||||
current_user: models.User = Depends(deps.get_current_active_superuser),
|
||||
) -> Any:
|
||||
"""
|
||||
Delete a product
|
||||
"""
|
||||
product = crud.product.get(db, id=product_id)
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
# Check for any pending orders with this product
|
||||
# TODO: Implement order item check
|
||||
|
||||
product = crud.product.remove(db, id=product_id)
|
||||
return product
|
1
app/core/__init__.py
Normal file
1
app/core/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Core module initialization
|
35
app/core/config.py
Normal file
35
app/core/config.py
Normal file
@ -0,0 +1,35 @@
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Base settings
|
||||
PROJECT_NAME: str = "Retail Management and Payment API"
|
||||
API_V1_STR: str = "/api/v1"
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: List[str] = ["*"]
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# Database
|
||||
DB_DIR = Path("/app") / "storage" / "db"
|
||||
DB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite"
|
||||
|
||||
# Stripe
|
||||
STRIPE_API_KEY: str = "your-stripe-api-key"
|
||||
STRIPE_WEBHOOK_SECRET: str = "your-stripe-webhook-secret"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
settings = Settings()
|
31
app/core/security.py
Normal file
31
app/core/security.py
Normal file
@ -0,0 +1,31 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
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.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
to_encode = {"exp": expire, "sub": str(subject)}
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_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)
|
5
app/crud/__init__.py
Normal file
5
app/crud/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
from app.crud.user import user
|
||||
from app.crud.product import product
|
||||
from app.crud.inventory import inventory
|
||||
from app.crud.customer import customer
|
||||
from app.crud.order import order
|
66
app/crud/base.py
Normal file
66
app/crud/base.py
Normal file
@ -0,0 +1,66 @@
|
||||
from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union
|
||||
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
ModelType = TypeVar("ModelType", bound=Base)
|
||||
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
|
||||
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
|
||||
|
||||
|
||||
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
|
||||
def __init__(self, model: Type[ModelType]):
|
||||
"""
|
||||
CRUD object with default methods to Create, Read, Update, Delete (CRUD).
|
||||
|
||||
**Parameters**
|
||||
|
||||
* `model`: A SQLAlchemy model class
|
||||
* `schema`: A Pydantic model (schema) class
|
||||
"""
|
||||
self.model = model
|
||||
|
||||
def get(self, db: Session, id: Any) -> Optional[ModelType]:
|
||||
return db.query(self.model).filter(self.model.id == id).first()
|
||||
|
||||
def get_multi(
|
||||
self, db: Session, *, skip: int = 0, limit: int = 100
|
||||
) -> List[ModelType]:
|
||||
return db.query(self.model).offset(skip).limit(limit).all()
|
||||
|
||||
def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
|
||||
obj_in_data = jsonable_encoder(obj_in)
|
||||
db_obj = self.model(**obj_in_data) # 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.dict(exclude_unset=True)
|
||||
for field in obj_data:
|
||||
if field in update_data:
|
||||
setattr(db_obj, field, update_data[field])
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def remove(self, db: Session, *, id: int) -> ModelType:
|
||||
obj = db.query(self.model).get(id)
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
return obj
|
36
app/crud/customer.py
Normal file
36
app/crud/customer.py
Normal file
@ -0,0 +1,36 @@
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud.base import CRUDBase
|
||||
from app.models.customer import Customer
|
||||
from app.schemas.customer import CustomerCreate, CustomerUpdate
|
||||
|
||||
|
||||
class CRUDCustomer(CRUDBase[Customer, CustomerCreate, CustomerUpdate]):
|
||||
def get_by_email(self, db: Session, *, email: str) -> Optional[Customer]:
|
||||
return db.query(Customer).filter(Customer.email == email).first()
|
||||
|
||||
def get_by_stripe_id(self, db: Session, *, stripe_customer_id: str) -> Optional[Customer]:
|
||||
return db.query(Customer).filter(Customer.stripe_customer_id == stripe_customer_id).first()
|
||||
|
||||
def create_with_stripe_id(self, db: Session, *, obj_in: CustomerCreate, stripe_customer_id: str) -> Customer:
|
||||
db_obj = Customer(
|
||||
first_name=obj_in.first_name,
|
||||
last_name=obj_in.last_name,
|
||||
email=obj_in.email,
|
||||
phone=obj_in.phone,
|
||||
address=obj_in.address,
|
||||
city=obj_in.city,
|
||||
state=obj_in.state,
|
||||
zip_code=obj_in.zip_code,
|
||||
country=obj_in.country,
|
||||
stripe_customer_id=stripe_customer_id
|
||||
)
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
|
||||
customer = CRUDCustomer(Customer)
|
27
app/crud/inventory.py
Normal file
27
app/crud/inventory.py
Normal file
@ -0,0 +1,27 @@
|
||||
from typing import List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.crud.base import CRUDBase
|
||||
from app.models.inventory import Inventory
|
||||
from app.schemas.inventory import InventoryCreate, InventoryUpdate
|
||||
|
||||
|
||||
class CRUDInventory(CRUDBase[Inventory, InventoryCreate, InventoryUpdate]):
|
||||
def get_by_product_id(self, db: Session, *, product_id: int) -> Inventory:
|
||||
return db.query(Inventory).filter(Inventory.product_id == product_id).first()
|
||||
|
||||
def update_quantity(self, db: Session, *, inventory_id: int, quantity_change: int) -> Inventory:
|
||||
inventory = self.get(db, id=inventory_id)
|
||||
if inventory:
|
||||
inventory.quantity += quantity_change
|
||||
db.add(inventory)
|
||||
db.commit()
|
||||
db.refresh(inventory)
|
||||
return inventory
|
||||
|
||||
def get_low_stock(self, db: Session, *, threshold: int = 10, skip: int = 0, limit: int = 100) -> List[Inventory]:
|
||||
return db.query(Inventory).filter(Inventory.quantity <= threshold).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
inventory = CRUDInventory(Inventory)
|
77
app/crud/order.py
Normal file
77
app/crud/order.py
Normal file
@ -0,0 +1,77 @@
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.crud.base import CRUDBase
|
||||
from app.models.order import Order, OrderItem
|
||||
from app.schemas.order import OrderCreate, OrderUpdate, OrderItemCreate
|
||||
|
||||
|
||||
class CRUDOrder(CRUDBase[Order, OrderCreate, OrderUpdate]):
|
||||
def get_by_order_number(self, db: Session, *, order_number: str) -> Optional[Order]:
|
||||
return db.query(Order).filter(Order.order_number == order_number).first()
|
||||
|
||||
def get_by_customer(self, db: Session, *, customer_id: int, skip: int = 0, limit: int = 100) -> List[Order]:
|
||||
return db.query(Order).filter(Order.customer_id == customer_id).order_by(desc(Order.created_at)).offset(skip).limit(limit).all()
|
||||
|
||||
def get_by_payment_intent(self, db: Session, *, payment_intent_id: str) -> Optional[Order]:
|
||||
return db.query(Order).filter(Order.payment_intent_id == payment_intent_id).first()
|
||||
|
||||
def get_with_items(self, db: Session, *, order_id: int) -> Optional[Order]:
|
||||
return db.query(Order).options(joinedload(Order.items)).filter(Order.id == order_id).first()
|
||||
|
||||
def create_with_items(self, db: Session, *, obj_in: OrderCreate) -> Order:
|
||||
# Generate unique order number
|
||||
order_number = f"ORD-{uuid.uuid4().hex[:8].upper()}"
|
||||
|
||||
# Create the order
|
||||
db_obj = Order(
|
||||
customer_id=obj_in.customer_id,
|
||||
order_number=order_number,
|
||||
status=obj_in.status,
|
||||
total_amount=obj_in.total_amount,
|
||||
payment_status="unpaid",
|
||||
shipping_address=obj_in.shipping_address,
|
||||
shipping_city=obj_in.shipping_city,
|
||||
shipping_state=obj_in.shipping_state,
|
||||
shipping_zip=obj_in.shipping_zip,
|
||||
shipping_country=obj_in.shipping_country
|
||||
)
|
||||
db.add(db_obj)
|
||||
db.flush() # Flush to get the order id
|
||||
|
||||
# Create the order items
|
||||
for item_in in obj_in.items:
|
||||
order_item = OrderItem(
|
||||
order_id=db_obj.id,
|
||||
product_id=item_in.product_id,
|
||||
quantity=item_in.quantity,
|
||||
unit_price=item_in.unit_price
|
||||
)
|
||||
db.add(order_item)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def update_payment_status(self, db: Session, *, order_id: int, payment_intent_id: str, payment_status: str) -> Order:
|
||||
order = self.get(db, id=order_id)
|
||||
if order:
|
||||
order.payment_intent_id = payment_intent_id
|
||||
order.payment_status = payment_status
|
||||
|
||||
# Update order status based on payment status
|
||||
if payment_status == "paid":
|
||||
order.status = "processing"
|
||||
elif payment_status == "refunded":
|
||||
order.status = "cancelled"
|
||||
|
||||
db.add(order)
|
||||
db.commit()
|
||||
db.refresh(order)
|
||||
return order
|
||||
|
||||
|
||||
order = CRUDOrder(Order)
|
44
app/crud/product.py
Normal file
44
app/crud/product.py
Normal file
@ -0,0 +1,44 @@
|
||||
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_active(self, db: Session, *, skip: int = 0, limit: int = 100) -> List[Product]:
|
||||
return db.query(Product).filter(Product.is_active == True).offset(skip).limit(limit).all()
|
||||
|
||||
def create_with_inventory(self, db: Session, *, obj_in: ProductCreate, inventory_quantity: int = 0) -> Product:
|
||||
from app.models.inventory import Inventory
|
||||
|
||||
# Create product
|
||||
db_obj = Product(
|
||||
name=obj_in.name,
|
||||
description=obj_in.description,
|
||||
sku=obj_in.sku,
|
||||
price=obj_in.price,
|
||||
image_url=obj_in.image_url,
|
||||
is_active=obj_in.is_active
|
||||
)
|
||||
db.add(db_obj)
|
||||
db.flush()
|
||||
|
||||
# Create inventory record
|
||||
inventory = Inventory(
|
||||
product_id=db_obj.id,
|
||||
quantity=inventory_quantity
|
||||
)
|
||||
db.add(inventory)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
|
||||
return db_obj
|
||||
|
||||
|
||||
product = CRUDProduct(Product)
|
60
app/crud/user.py
Normal file
60
app/crud/user.py
Normal file
@ -0,0 +1,60 @@
|
||||
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=obj_in.is_superuser,
|
||||
)
|
||||
db.add(db_obj)
|
||||
db.commit()
|
||||
db.refresh(db_obj)
|
||||
return db_obj
|
||||
|
||||
def update(
|
||||
self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]
|
||||
) -> User:
|
||||
if isinstance(obj_in, dict):
|
||||
update_data = obj_in
|
||||
else:
|
||||
update_data = obj_in.dict(exclude_unset=True)
|
||||
if update_data.get("password"):
|
||||
hashed_password = get_password_hash(update_data["password"])
|
||||
del update_data["password"]
|
||||
update_data["hashed_password"] = hashed_password
|
||||
return super().update(db, db_obj=db_obj, obj_in=update_data)
|
||||
|
||||
def authenticate(self, db: Session, *, 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)
|
1
app/db/__init__.py
Normal file
1
app/db/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Database initialization
|
8
app/db/base.py
Normal file
8
app/db/base.py
Normal file
@ -0,0 +1,8 @@
|
||||
# Import all the models, so that Base has them before being
|
||||
# imported by Alembic
|
||||
from app.db.base_class import Base # noqa
|
||||
from app.models.product import Product # noqa
|
||||
from app.models.inventory import Inventory # noqa
|
||||
from app.models.customer import Customer # noqa
|
||||
from app.models.order import Order, OrderItem # noqa
|
||||
from app.models.user import User # noqa
|
14
app/db/base_class.py
Normal file
14
app/db/base_class.py
Normal file
@ -0,0 +1,14 @@
|
||||
from typing import Any
|
||||
|
||||
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()
|
18
app/db/session.py
Normal file
18
app/db/session.py
Normal file
@ -0,0 +1,18 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(
|
||||
settings.SQLALCHEMY_DATABASE_URL,
|
||||
connect_args={"check_same_thread": False}
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
6
app/models/__init__.py
Normal file
6
app/models/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
# Import all models here
|
||||
from app.models.user import User
|
||||
from app.models.product import Product
|
||||
from app.models.inventory import Inventory
|
||||
from app.models.customer import Customer
|
||||
from app.models.order import Order, OrderItem
|
26
app/models/customer.py
Normal file
26
app/models/customer.py
Normal file
@ -0,0 +1,26 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("user.id"), nullable=True)
|
||||
first_name = Column(String, nullable=False)
|
||||
last_name = Column(String, nullable=False)
|
||||
email = Column(String, unique=True, index=True)
|
||||
phone = Column(String, nullable=True)
|
||||
address = Column(String, nullable=True)
|
||||
city = Column(String, nullable=True)
|
||||
state = Column(String, nullable=True)
|
||||
zip_code = Column(String, nullable=True)
|
||||
country = Column(String, nullable=True)
|
||||
stripe_customer_id = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
orders = relationship("Order", back_populates="customer")
|
||||
user = relationship("User")
|
18
app/models/inventory.py
Normal file
18
app/models/inventory.py
Normal file
@ -0,0 +1,18 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class Inventory(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("product.id"), unique=True)
|
||||
quantity = Column(Integer, nullable=False, default=0)
|
||||
location = Column(String, nullable=True)
|
||||
last_restock_date = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
product = relationship("Product", back_populates="inventory")
|
39
app/models/order.py
Normal file
39
app/models/order.py
Normal file
@ -0,0 +1,39 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Table
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class Order(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
customer_id = Column(Integer, ForeignKey("customer.id"))
|
||||
order_number = Column(String, unique=True, index=True)
|
||||
status = Column(String, default="pending") # pending, paid, shipped, delivered, cancelled
|
||||
total_amount = Column(Float)
|
||||
payment_intent_id = Column(String, nullable=True)
|
||||
payment_status = Column(String, default="unpaid") # unpaid, paid, refunded
|
||||
shipping_address = Column(String, nullable=True)
|
||||
shipping_city = Column(String, nullable=True)
|
||||
shipping_state = Column(String, nullable=True)
|
||||
shipping_zip = Column(String, nullable=True)
|
||||
shipping_country = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
customer = relationship("Customer", back_populates="orders")
|
||||
items = relationship("OrderItem", back_populates="order")
|
||||
|
||||
|
||||
class OrderItem(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("order.id"))
|
||||
product_id = Column(Integer, ForeignKey("product.id"))
|
||||
quantity = Column(Integer, default=1)
|
||||
unit_price = Column(Float)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
order = relationship("Order", back_populates="items")
|
||||
product = relationship("Product", back_populates="order_items")
|
21
app/models/product.py
Normal file
21
app/models/product.py
Normal file
@ -0,0 +1,21 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Text, Boolean, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class Product(Base):
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
sku = Column(String, unique=True, index=True)
|
||||
price = Column(Float, nullable=False)
|
||||
image_url = Column(String, nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
inventory = relationship("Inventory", back_populates="product", uselist=False)
|
||||
order_items = relationship("OrderItem", back_populates="product")
|
16
app/models/user.py
Normal file
16
app/models/user.py
Normal file
@ -0,0 +1,16 @@
|
||||
from sqlalchemy import Boolean, Column, Integer, String, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
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)
|
||||
username = Column(String, unique=True, index=True)
|
||||
hashed_password = Column(String)
|
||||
full_name = Column(String, nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_superuser = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
6
app/schemas/__init__.py
Normal file
6
app/schemas/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
# Import all schemas here
|
||||
from app.schemas.user import User, UserCreate, UserUpdate, Token
|
||||
from app.schemas.product import Product, ProductCreate, ProductUpdate, ProductWithInventory
|
||||
from app.schemas.inventory import Inventory, InventoryCreate, InventoryUpdate
|
||||
from app.schemas.customer import Customer, CustomerCreate, CustomerUpdate
|
||||
from app.schemas.order import Order, OrderCreate, OrderUpdate, OrderItem, PaymentIntentResponse
|
42
app/schemas/customer.py
Normal file
42
app/schemas/customer.py
Normal file
@ -0,0 +1,42 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class CustomerBase(BaseModel):
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: EmailStr
|
||||
phone: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
zip_code: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
|
||||
|
||||
class CustomerCreate(CustomerBase):
|
||||
pass
|
||||
|
||||
|
||||
class CustomerUpdate(BaseModel):
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
phone: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
state: Optional[str] = None
|
||||
zip_code: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
|
||||
|
||||
class CustomerInDBBase(CustomerBase):
|
||||
id: int
|
||||
stripe_customer_id: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class Customer(CustomerInDBBase):
|
||||
pass
|
31
app/schemas/inventory.py
Normal file
31
app/schemas/inventory.py
Normal file
@ -0,0 +1,31 @@
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class InventoryBase(BaseModel):
|
||||
product_id: int
|
||||
quantity: int
|
||||
location: Optional[str] = None
|
||||
last_restock_date: Optional[datetime] = None
|
||||
|
||||
|
||||
class InventoryCreate(InventoryBase):
|
||||
pass
|
||||
|
||||
|
||||
class InventoryUpdate(BaseModel):
|
||||
quantity: Optional[int] = None
|
||||
location: Optional[str] = None
|
||||
last_restock_date: Optional[datetime] = None
|
||||
|
||||
|
||||
class InventoryInDBBase(InventoryBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class Inventory(InventoryInDBBase):
|
||||
pass
|
71
app/schemas/order.py
Normal file
71
app/schemas/order.py
Normal file
@ -0,0 +1,71 @@
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OrderItemBase(BaseModel):
|
||||
product_id: int
|
||||
quantity: int = 1
|
||||
unit_price: float
|
||||
|
||||
|
||||
class OrderItemCreate(OrderItemBase):
|
||||
pass
|
||||
|
||||
|
||||
class OrderItemInDBBase(OrderItemBase):
|
||||
id: int
|
||||
order_id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class OrderItem(OrderItemInDBBase):
|
||||
pass
|
||||
|
||||
|
||||
class OrderBase(BaseModel):
|
||||
customer_id: int
|
||||
status: str = "pending"
|
||||
total_amount: float
|
||||
shipping_address: Optional[str] = None
|
||||
shipping_city: Optional[str] = None
|
||||
shipping_state: Optional[str] = None
|
||||
shipping_zip: Optional[str] = None
|
||||
shipping_country: Optional[str] = None
|
||||
|
||||
|
||||
class OrderCreate(OrderBase):
|
||||
items: List[OrderItemCreate]
|
||||
|
||||
|
||||
class OrderUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
payment_status: Optional[str] = None
|
||||
shipping_address: Optional[str] = None
|
||||
shipping_city: Optional[str] = None
|
||||
shipping_state: Optional[str] = None
|
||||
shipping_zip: Optional[str] = None
|
||||
shipping_country: Optional[str] = None
|
||||
|
||||
|
||||
class OrderInDBBase(OrderBase):
|
||||
id: int
|
||||
order_number: str
|
||||
payment_intent_id: Optional[str] = None
|
||||
payment_status: str
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class Order(OrderInDBBase):
|
||||
items: List[OrderItem] = []
|
||||
|
||||
|
||||
class PaymentIntentResponse(BaseModel):
|
||||
client_secret: str
|
||||
payment_intent_id: str
|
39
app/schemas/product.py
Normal file
39
app/schemas/product.py
Normal file
@ -0,0 +1,39 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProductBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
sku: str
|
||||
price: float
|
||||
image_url: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class ProductCreate(ProductBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
sku: Optional[str] = None
|
||||
price: Optional[float] = None
|
||||
image_url: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class ProductInDBBase(ProductBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class Product(ProductInDBBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProductWithInventory(Product):
|
||||
inventory_quantity: int = 0
|
44
app/schemas/user.py
Normal file
44
app/schemas/user.py
Normal file
@ -0,0 +1,44 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
email: EmailStr
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserUpdate(UserBase):
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class UserInDBBase(UserBase):
|
||||
id: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class User(UserInDBBase):
|
||||
pass
|
||||
|
||||
|
||||
class UserInDB(UserInDBBase):
|
||||
hashed_password: str
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
sub: Optional[int] = None
|
27
main.py
Normal file
27
main.py
Normal file
@ -0,0 +1,27 @@
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import router as api_router
|
||||
from app.core.config import settings
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
description="Retail Management and Payment API",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
# Set up CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(api_router)
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
12
requirements.txt
Normal file
12
requirements.txt
Normal file
@ -0,0 +1,12 @@
|
||||
fastapi==0.99.1
|
||||
uvicorn==0.23.2
|
||||
sqlalchemy==2.0.19
|
||||
pydantic==2.1.1
|
||||
pydantic-settings==2.0.3
|
||||
alembic==1.11.1
|
||||
stripe==6.5.0
|
||||
python-dotenv==1.0.0
|
||||
python-multipart==0.0.6
|
||||
passlib==1.7.4
|
||||
python-jose==3.3.0
|
||||
bcrypt==4.0.1
|
Loading…
x
Reference in New Issue
Block a user