diff --git a/README.md b/README.md index e8acfba..94b86d8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,124 @@ -# FastAPI Application +# Bookstore Management API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A comprehensive REST API for managing a bookstore with inventory management and Stripe payment processing built with FastAPI. + +## Features + +- **Book Management**: Complete CRUD operations for books with search and filtering +- **Inventory Management**: Track stock levels, reserve items, and manage restocking +- **Order Management**: Handle customer orders with status tracking +- **Payment Processing**: Secure payment handling with Stripe integration +- **Database**: SQLite database with SQLAlchemy ORM and Alembic migrations + +## API Endpoints + +### Books +- `GET /api/v1/books/` - List all books with filtering options +- `POST /api/v1/books/` - Create a new book +- `GET /api/v1/books/{book_id}` - Get a specific book +- `PUT /api/v1/books/{book_id}` - Update a book +- `DELETE /api/v1/books/{book_id}` - Deactivate a book + +### Inventory +- `GET /api/v1/inventory/` - List inventory items +- `POST /api/v1/inventory/` - Create inventory entry +- `GET /api/v1/inventory/{inventory_id}` - Get inventory item +- `GET /api/v1/inventory/book/{book_id}` - Get inventory by book +- `PUT /api/v1/inventory/{inventory_id}` - Update inventory +- `POST /api/v1/inventory/{inventory_id}/restock` - Restock items +- `POST /api/v1/inventory/{inventory_id}/reserve` - Reserve items +- `POST /api/v1/inventory/{inventory_id}/release` - Release reserved items + +### Orders +- `GET /api/v1/orders/` - List orders with filtering +- `POST /api/v1/orders/` - Create a new order +- `GET /api/v1/orders/{order_id}` - Get order details +- `PUT /api/v1/orders/{order_id}/status` - Update order status +- `POST /api/v1/orders/payment-intent` - Create Stripe payment intent +- `POST /api/v1/orders/{order_id}/confirm-payment` - Confirm payment +- `DELETE /api/v1/orders/{order_id}` - Cancel order + +### System +- `GET /` - API information +- `GET /health` - Health check endpoint +- `GET /docs` - Interactive API documentation +- `GET /redoc` - ReDoc API documentation + +## Installation & Setup + +1. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +2. Set up environment variables: + ```bash + export STRIPE_SECRET_KEY=your_stripe_secret_key_here + ``` + +3. Run database migrations: + ```bash + alembic upgrade head + ``` + +4. Start the application: + ```bash + uvicorn main:app --reload --host 0.0.0.0 --port 8000 + ``` + +## Environment Variables + +The following environment variables need to be set: + +- `STRIPE_SECRET_KEY`: Your Stripe secret key for payment processing + +## Database + +The application uses SQLite with the database file located at `/app/storage/db/db.sqlite`. The database schema includes: + +- **books**: Book catalog with details +- **inventory**: Stock management for books +- **orders**: Customer orders +- **order_items**: Items within orders + +## Development + +### Linting +Run code linting with: +```bash +ruff check --fix . +``` + +### Database Migrations +Create new migrations: +```bash +alembic revision --autogenerate -m "Description" +``` + +Apply migrations: +```bash +alembic upgrade head +``` + +## API Documentation + +Once the server is running, visit: +- http://localhost:8000/docs for Swagger UI +- http://localhost:8000/redoc for ReDoc +- http://localhost:8000/openapi.json for OpenAPI schema + +## Payment Flow + +1. Create an order with items +2. Create a payment intent for the order +3. Process the payment on the frontend using Stripe +4. Confirm the payment to update order status +5. Manage order fulfillment through status updates + +## Error Handling + +The API provides comprehensive error handling with appropriate HTTP status codes and descriptive error messages for various scenarios including: +- Invalid data validation +- Insufficient inventory +- Payment processing errors +- Resource not found \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..017f263 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + +[post_write_hooks] + +[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/env.py b/alembic/env.py new file mode 100644 index 0000000..e1ca1aa --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,49 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context +import sys +import os + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.db.base import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..37d0cac --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/alembic/versions/001_initial_migration.py b/alembic/versions/001_initial_migration.py new file mode 100644 index 0000000..982b4b8 --- /dev/null +++ b/alembic/versions/001_initial_migration.py @@ -0,0 +1,92 @@ +"""Initial migration + +Revision ID: 001 +Revises: +Create Date: 2024-01-01 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '001' +down_revision = None +branch_labels = None +depends_on = None + +def upgrade() -> None: + # Create books table + op.create_table('books', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('author', sa.String(length=255), nullable=False), + sa.Column('isbn', sa.String(length=13), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('category', sa.String(length=100), nullable=False), + sa.Column('publisher', sa.String(length=255), nullable=True), + sa.Column('publication_date', sa.DateTime(), nullable=True), + sa.Column('pages', sa.Integer(), nullable=True), + sa.Column('language', sa.String(length=50), 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_books_id'), 'books', ['id'], unique=False) + op.create_index(op.f('ix_books_isbn'), 'books', ['isbn'], unique=True) + op.create_index(op.f('ix_books_title'), 'books', ['title'], unique=False) + + # Create inventory table + op.create_table('inventory', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('book_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.Column('reserved_quantity', sa.Integer(), nullable=False), + sa.Column('reorder_level', sa.Integer(), nullable=False), + 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(['book_id'], ['books.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_inventory_id'), 'inventory', ['id'], unique=False) + + # Create orders table + op.create_table('orders', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('customer_email', sa.String(length=255), nullable=False), + sa.Column('customer_name', sa.String(length=255), nullable=False), + sa.Column('customer_address', sa.Text(), nullable=False), + sa.Column('total_amount', sa.Float(), nullable=False), + sa.Column('status', sa.Enum('PENDING', 'CONFIRMED', 'SHIPPED', 'DELIVERED', 'CANCELLED', name='orderstatus'), nullable=True), + sa.Column('stripe_payment_intent_id', sa.String(length=255), 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_orders_id'), 'orders', ['id'], unique=False) + + # Create order_items table + op.create_table('order_items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('order_id', sa.Integer(), nullable=False), + sa.Column('book_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.Column('price', sa.Float(), nullable=False), + sa.ForeignKeyConstraint(['book_id'], ['books.id'], ), + sa.ForeignKeyConstraint(['order_id'], ['orders.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_order_items_id'), 'order_items', ['id'], unique=False) + +def downgrade() -> None: + op.drop_index(op.f('ix_order_items_id'), table_name='order_items') + op.drop_table('order_items') + op.drop_index(op.f('ix_orders_id'), table_name='orders') + op.drop_table('orders') + op.drop_index(op.f('ix_inventory_id'), table_name='inventory') + op.drop_table('inventory') + op.drop_index(op.f('ix_books_title'), table_name='books') + op.drop_index(op.f('ix_books_isbn'), table_name='books') + op.drop_index(op.f('ix_books_id'), table_name='books') + op.drop_table('books') \ 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/books.py b/app/api/books.py new file mode 100644 index 0000000..22eeca7 --- /dev/null +++ b/app/api/books.py @@ -0,0 +1,85 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import List, Optional +from app.db.session import get_db +from app.models import Book, Inventory +from app.schemas import BookCreate, BookUpdate, BookResponse + +router = APIRouter(prefix="/books", tags=["books"]) + +@router.post("/", response_model=BookResponse) +def create_book(book: BookCreate, db: Session = Depends(get_db)): + db_book = db.query(Book).filter(Book.isbn == book.isbn).first() + if db_book: + raise HTTPException(status_code=400, detail="Book with this ISBN already exists") + + db_book = Book(**book.model_dump()) + db.add(db_book) + db.commit() + db.refresh(db_book) + + inventory = Inventory(book_id=db_book.id, quantity=0) + db.add(inventory) + db.commit() + + return db_book + +@router.get("/", response_model=List[BookResponse]) +def get_books( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + category: Optional[str] = None, + author: Optional[str] = None, + search: Optional[str] = None, + db: Session = Depends(get_db) +): + query = db.query(Book).filter(Book.is_active) + + if category: + query = query.filter(Book.category.ilike(f"%{category}%")) + if author: + query = query.filter(Book.author.ilike(f"%{author}%")) + if search: + query = query.filter( + (Book.title.ilike(f"%{search}%")) | + (Book.author.ilike(f"%{search}%")) | + (Book.description.ilike(f"%{search}%")) + ) + + return query.offset(skip).limit(limit).all() + +@router.get("/{book_id}", response_model=BookResponse) +def get_book(book_id: int, db: Session = Depends(get_db)): + book = db.query(Book).filter(Book.id == book_id, Book.is_active).first() + if not book: + raise HTTPException(status_code=404, detail="Book not found") + return book + +@router.put("/{book_id}", response_model=BookResponse) +def update_book(book_id: int, book_update: BookUpdate, db: Session = Depends(get_db)): + book = db.query(Book).filter(Book.id == book_id).first() + if not book: + raise HTTPException(status_code=404, detail="Book not found") + + update_data = book_update.model_dump(exclude_unset=True) + if "isbn" in update_data: + existing_book = db.query(Book).filter(Book.isbn == update_data["isbn"], Book.id != book_id).first() + if existing_book: + raise HTTPException(status_code=400, detail="Book with this ISBN already exists") + + for field, value in update_data.items(): + setattr(book, field, value) + + db.commit() + db.refresh(book) + return book + +@router.delete("/{book_id}") +def delete_book(book_id: int, db: Session = Depends(get_db)): + book = db.query(Book).filter(Book.id == book_id).first() + if not book: + raise HTTPException(status_code=404, detail="Book not found") + + book.is_active = False + db.commit() + return {"message": "Book deactivated successfully"} \ No newline at end of file diff --git a/app/api/inventory.py b/app/api/inventory.py new file mode 100644 index 0000000..98cd69b --- /dev/null +++ b/app/api/inventory.py @@ -0,0 +1,119 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import List +from app.db.session import get_db +from app.models import Inventory, Book +from app.schemas import InventoryCreate, InventoryUpdate, InventoryResponse + +router = APIRouter(prefix="/inventory", tags=["inventory"]) + +@router.post("/", response_model=InventoryResponse) +def create_inventory(inventory: InventoryCreate, db: Session = Depends(get_db)): + book = db.query(Book).filter(Book.id == inventory.book_id).first() + if not book: + raise HTTPException(status_code=404, detail="Book not found") + + existing_inventory = db.query(Inventory).filter(Inventory.book_id == inventory.book_id).first() + if existing_inventory: + raise HTTPException(status_code=400, detail="Inventory for this book already exists") + + db_inventory = Inventory(**inventory.model_dump()) + db.add(db_inventory) + db.commit() + db.refresh(db_inventory) + + setattr(db_inventory, 'available_quantity', db_inventory.quantity - db_inventory.reserved_quantity) + return db_inventory + +@router.get("/", response_model=List[InventoryResponse]) +def get_inventory( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + low_stock: bool = Query(False, description="Filter items with low stock"), + db: Session = Depends(get_db) +): + query = db.query(Inventory) + + if low_stock: + query = query.filter(Inventory.quantity <= Inventory.reorder_level) + + inventories = query.offset(skip).limit(limit).all() + + for inventory in inventories: + setattr(inventory, 'available_quantity', inventory.quantity - inventory.reserved_quantity) + + return inventories + +@router.get("/{inventory_id}", response_model=InventoryResponse) +def get_inventory_item(inventory_id: int, db: Session = Depends(get_db)): + inventory = db.query(Inventory).filter(Inventory.id == inventory_id).first() + if not inventory: + raise HTTPException(status_code=404, detail="Inventory item not found") + + setattr(inventory, 'available_quantity', inventory.quantity - inventory.reserved_quantity) + return inventory + +@router.get("/book/{book_id}", response_model=InventoryResponse) +def get_inventory_by_book(book_id: int, db: Session = Depends(get_db)): + inventory = db.query(Inventory).filter(Inventory.book_id == book_id).first() + if not inventory: + raise HTTPException(status_code=404, detail="Inventory for this book not found") + + setattr(inventory, 'available_quantity', inventory.quantity - inventory.reserved_quantity) + return inventory + +@router.put("/{inventory_id}", response_model=InventoryResponse) +def update_inventory(inventory_id: int, inventory_update: InventoryUpdate, db: Session = Depends(get_db)): + inventory = db.query(Inventory).filter(Inventory.id == inventory_id).first() + if not inventory: + raise HTTPException(status_code=404, detail="Inventory item not found") + + update_data = inventory_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(inventory, field, value) + + db.commit() + db.refresh(inventory) + + setattr(inventory, 'available_quantity', inventory.quantity - inventory.reserved_quantity) + return inventory + +@router.post("/{inventory_id}/restock") +def restock_inventory(inventory_id: int, quantity: int = Query(..., gt=0), db: Session = Depends(get_db)): + inventory = db.query(Inventory).filter(Inventory.id == inventory_id).first() + if not inventory: + raise HTTPException(status_code=404, detail="Inventory item not found") + + inventory.quantity += quantity + db.commit() + + return {"message": f"Restocked {quantity} units. New quantity: {inventory.quantity}"} + +@router.post("/{inventory_id}/reserve") +def reserve_inventory(inventory_id: int, quantity: int = Query(..., gt=0), db: Session = Depends(get_db)): + inventory = db.query(Inventory).filter(Inventory.id == inventory_id).first() + if not inventory: + raise HTTPException(status_code=404, detail="Inventory item not found") + + available = inventory.quantity - inventory.reserved_quantity + if quantity > available: + raise HTTPException(status_code=400, detail=f"Not enough stock available. Available: {available}") + + inventory.reserved_quantity += quantity + db.commit() + + return {"message": f"Reserved {quantity} units"} + +@router.post("/{inventory_id}/release") +def release_inventory(inventory_id: int, quantity: int = Query(..., gt=0), db: Session = Depends(get_db)): + inventory = db.query(Inventory).filter(Inventory.id == inventory_id).first() + if not inventory: + raise HTTPException(status_code=404, detail="Inventory item not found") + + if quantity > inventory.reserved_quantity: + raise HTTPException(status_code=400, detail="Cannot release more than reserved quantity") + + inventory.reserved_quantity -= quantity + db.commit() + + return {"message": f"Released {quantity} units from reservation"} \ No newline at end of file diff --git a/app/api/orders.py b/app/api/orders.py new file mode 100644 index 0000000..0b290bc --- /dev/null +++ b/app/api/orders.py @@ -0,0 +1,177 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from typing import List, Optional +from app.db.session import get_db +from app.models import Order, OrderItem, Book, Inventory, OrderStatus +from app.schemas import OrderCreate, OrderResponse, PaymentIntentCreate +from app.services.stripe_service import StripeService + +router = APIRouter(prefix="/orders", tags=["orders"]) + +@router.post("/", response_model=OrderResponse) +def create_order(order: OrderCreate, db: Session = Depends(get_db)): + total_amount = 0 + order_items_data = [] + + for item in order.items: + book = db.query(Book).filter(Book.id == item.book_id, Book.is_active).first() + if not book: + raise HTTPException(status_code=404, detail=f"Book with ID {item.book_id} not found") + + inventory = db.query(Inventory).filter(Inventory.book_id == item.book_id).first() + if not inventory: + raise HTTPException(status_code=400, detail=f"No inventory found for book ID {item.book_id}") + + available = inventory.quantity - inventory.reserved_quantity + if item.quantity > available: + raise HTTPException(status_code=400, detail=f"Not enough stock for book '{book.title}'. Available: {available}") + + item_total = book.price * item.quantity + total_amount += item_total + + order_items_data.append({ + "book_id": item.book_id, + "quantity": item.quantity, + "price": book.price + }) + + db_order = Order( + customer_email=order.customer_email, + customer_name=order.customer_name, + customer_address=order.customer_address, + total_amount=total_amount + ) + db.add(db_order) + db.commit() + db.refresh(db_order) + + for item_data in order_items_data: + order_item = OrderItem(order_id=db_order.id, **item_data) + db.add(order_item) + + inventory = db.query(Inventory).filter(Inventory.book_id == item_data["book_id"]).first() + inventory.reserved_quantity += item_data["quantity"] + + db.commit() + db.refresh(db_order) + + return db_order + +@router.get("/", response_model=List[OrderResponse]) +def get_orders( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + status: Optional[OrderStatus] = None, + customer_email: Optional[str] = None, + db: Session = Depends(get_db) +): + query = db.query(Order) + + if status: + query = query.filter(Order.status == status) + if customer_email: + query = query.filter(Order.customer_email.ilike(f"%{customer_email}%")) + + return query.offset(skip).limit(limit).all() + +@router.get("/{order_id}", response_model=OrderResponse) +def get_order(order_id: int, db: Session = Depends(get_db)): + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + return order + +@router.put("/{order_id}/status") +def update_order_status(order_id: int, status: OrderStatus, db: Session = Depends(get_db)): + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + if order.status == OrderStatus.CANCELLED: + raise HTTPException(status_code=400, detail="Cannot update status of cancelled order") + + if status == OrderStatus.CANCELLED: + for item in order.items: + inventory = db.query(Inventory).filter(Inventory.book_id == item.book_id).first() + if inventory: + inventory.reserved_quantity -= item.quantity + + if status == OrderStatus.DELIVERED and order.status != OrderStatus.SHIPPED: + raise HTTPException(status_code=400, detail="Order must be shipped before it can be delivered") + + if status == OrderStatus.CONFIRMED and order.status == OrderStatus.PENDING: + for item in order.items: + inventory = db.query(Inventory).filter(Inventory.book_id == item.book_id).first() + if inventory: + inventory.quantity -= item.quantity + inventory.reserved_quantity -= item.quantity + + order.status = status + db.commit() + + return {"message": f"Order status updated to {status.value}"} + +@router.post("/payment-intent", response_model=dict) +def create_payment_intent(payment_data: PaymentIntentCreate, db: Session = Depends(get_db)): + order = db.query(Order).filter(Order.id == payment_data.order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + if order.stripe_payment_intent_id: + raise HTTPException(status_code=400, detail="Payment intent already exists for this order") + + stripe_service = StripeService() + payment_intent = stripe_service.create_payment_intent( + amount=order.total_amount, + metadata={"order_id": str(order.id)} + ) + + order.stripe_payment_intent_id = payment_intent["payment_intent_id"] + db.commit() + + return payment_intent + +@router.post("/{order_id}/confirm-payment") +def confirm_payment(order_id: int, db: Session = Depends(get_db)): + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + if not order.stripe_payment_intent_id: + raise HTTPException(status_code=400, detail="No payment intent found for this order") + + stripe_service = StripeService() + payment_status = stripe_service.confirm_payment_intent(order.stripe_payment_intent_id) + + if payment_status["status"] == "succeeded": + order.status = OrderStatus.CONFIRMED + db.commit() + return {"message": "Payment confirmed and order updated"} + else: + return {"message": "Payment not yet completed", "status": payment_status["status"]} + +@router.delete("/{order_id}") +def cancel_order(order_id: int, db: Session = Depends(get_db)): + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + if order.status in [OrderStatus.SHIPPED, OrderStatus.DELIVERED]: + raise HTTPException(status_code=400, detail="Cannot cancel shipped or delivered order") + + for item in order.items: + inventory = db.query(Inventory).filter(Inventory.book_id == item.book_id).first() + if inventory: + inventory.reserved_quantity -= item.quantity + + if order.stripe_payment_intent_id: + stripe_service = StripeService() + try: + stripe_service.cancel_payment_intent(order.stripe_payment_intent_id) + except HTTPException: + pass + + order.status = OrderStatus.CANCELLED + db.commit() + + return {"message": "Order cancelled successfully"} \ No newline at end of file diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..7c2377a --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,3 @@ +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..684d6e7 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,22 @@ +from pathlib import Path +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +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) + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..291c45c --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,5 @@ +from .book import Book +from .inventory import Inventory +from .order import Order, OrderItem, OrderStatus + +__all__ = ["Book", "Inventory", "Order", "OrderItem", "OrderStatus"] \ No newline at end of file diff --git a/app/models/book.py b/app/models/book.py new file mode 100644 index 0000000..02ae376 --- /dev/null +++ b/app/models/book.py @@ -0,0 +1,21 @@ +from sqlalchemy import Column, Integer, String, Float, Text, DateTime, Boolean +from sqlalchemy.sql import func +from app.db.base import Base + +class Book(Base): + __tablename__ = "books" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(255), nullable=False, index=True) + author = Column(String(255), nullable=False) + isbn = Column(String(13), unique=True, nullable=False, index=True) + description = Column(Text, nullable=True) + price = Column(Float, nullable=False) + category = Column(String(100), nullable=False) + publisher = Column(String(255), nullable=True) + publication_date = Column(DateTime, nullable=True) + pages = Column(Integer, nullable=True) + language = Column(String(50), default="English") + 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()) \ No newline at end of file diff --git a/app/models/inventory.py b/app/models/inventory.py new file mode 100644 index 0000000..a51b5d9 --- /dev/null +++ b/app/models/inventory.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, Integer, ForeignKey, DateTime +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.db.base import Base + +class Inventory(Base): + __tablename__ = "inventory" + + id = Column(Integer, primary_key=True, index=True) + book_id = Column(Integer, ForeignKey("books.id"), nullable=False) + quantity = Column(Integer, nullable=False, default=0) + reserved_quantity = Column(Integer, nullable=False, default=0) + reorder_level = Column(Integer, nullable=False, default=10) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + book = relationship("Book", backref="inventory") \ No newline at end of file diff --git a/app/models/order.py b/app/models/order.py new file mode 100644 index 0000000..52e077b --- /dev/null +++ b/app/models/order.py @@ -0,0 +1,37 @@ +from sqlalchemy import Column, Integer, String, Float, DateTime, Enum, ForeignKey, Text +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +import enum +from app.db.base import Base + +class OrderStatus(enum.Enum): + PENDING = "pending" + CONFIRMED = "confirmed" + SHIPPED = "shipped" + DELIVERED = "delivered" + CANCELLED = "cancelled" + +class Order(Base): + __tablename__ = "orders" + + id = Column(Integer, primary_key=True, index=True) + customer_email = Column(String(255), nullable=False) + customer_name = Column(String(255), nullable=False) + customer_address = Column(Text, nullable=False) + total_amount = Column(Float, nullable=False) + status = Column(Enum(OrderStatus), default=OrderStatus.PENDING) + stripe_payment_intent_id = Column(String(255), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + +class OrderItem(Base): + __tablename__ = "order_items" + + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) + book_id = Column(Integer, ForeignKey("books.id"), nullable=False) + quantity = Column(Integer, nullable=False) + price = Column(Float, nullable=False) + + order = relationship("Order", backref="items") + book = relationship("Book") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..b698be8 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,9 @@ +from .book import BookCreate, BookUpdate, BookResponse +from .inventory import InventoryCreate, InventoryUpdate, InventoryResponse +from .order import OrderCreate, OrderResponse, PaymentIntentCreate, OrderItemResponse + +__all__ = [ + "BookCreate", "BookUpdate", "BookResponse", + "InventoryCreate", "InventoryUpdate", "InventoryResponse", + "OrderCreate", "OrderResponse", "PaymentIntentCreate", "OrderItemResponse" +] \ No newline at end of file diff --git a/app/schemas/book.py b/app/schemas/book.py new file mode 100644 index 0000000..d449332 --- /dev/null +++ b/app/schemas/book.py @@ -0,0 +1,40 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional + +class BookBase(BaseModel): + title: str = Field(..., min_length=1, max_length=255) + author: str = Field(..., min_length=1, max_length=255) + isbn: str = Field(..., min_length=10, max_length=13) + description: Optional[str] = None + price: float = Field(..., gt=0) + category: str = Field(..., min_length=1, max_length=100) + publisher: Optional[str] = None + publication_date: Optional[datetime] = None + pages: Optional[int] = Field(None, gt=0) + language: str = Field(default="English", max_length=50) + +class BookCreate(BookBase): + pass + +class BookUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=255) + author: Optional[str] = Field(None, min_length=1, max_length=255) + isbn: Optional[str] = Field(None, min_length=10, max_length=13) + description: Optional[str] = None + price: Optional[float] = Field(None, gt=0) + category: Optional[str] = Field(None, min_length=1, max_length=100) + publisher: Optional[str] = None + publication_date: Optional[datetime] = None + pages: Optional[int] = Field(None, gt=0) + language: Optional[str] = Field(None, max_length=50) + is_active: Optional[bool] = None + +class BookResponse(BookBase): + id: int + is_active: bool + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/schemas/inventory.py b/app/schemas/inventory.py new file mode 100644 index 0000000..fd474ed --- /dev/null +++ b/app/schemas/inventory.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional + +class InventoryBase(BaseModel): + book_id: int + quantity: int = Field(..., ge=0) + reserved_quantity: int = Field(default=0, ge=0) + reorder_level: int = Field(default=10, ge=0) + +class InventoryCreate(InventoryBase): + pass + +class InventoryUpdate(BaseModel): + quantity: Optional[int] = Field(None, ge=0) + reserved_quantity: Optional[int] = Field(None, ge=0) + reorder_level: Optional[int] = Field(None, ge=0) + +class InventoryResponse(InventoryBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + available_quantity: int + + class Config: + from_attributes = True \ No newline at end of file diff --git a/app/schemas/order.py b/app/schemas/order.py new file mode 100644 index 0000000..d34a309 --- /dev/null +++ b/app/schemas/order.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel, Field, EmailStr +from datetime import datetime +from typing import List, Optional +from app.models.order import OrderStatus + +class OrderItemBase(BaseModel): + book_id: int + quantity: int = Field(..., gt=0) + +class OrderItemCreate(OrderItemBase): + pass + +class OrderItemResponse(OrderItemBase): + id: int + price: float + + class Config: + from_attributes = True + +class OrderBase(BaseModel): + customer_email: EmailStr + customer_name: str = Field(..., min_length=1, max_length=255) + customer_address: str = Field(..., min_length=1) + +class OrderCreate(OrderBase): + items: List[OrderItemCreate] + +class OrderResponse(OrderBase): + id: int + total_amount: float + status: OrderStatus + stripe_payment_intent_id: Optional[str] = None + items: List[OrderItemResponse] + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + +class PaymentIntentCreate(BaseModel): + order_id: int \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/stripe_service.py b/app/services/stripe_service.py new file mode 100644 index 0000000..2db6a20 --- /dev/null +++ b/app/services/stripe_service.py @@ -0,0 +1,48 @@ +import stripe +import os +from typing import Dict, Any +from fastapi import HTTPException + +stripe.api_key = os.getenv("STRIPE_SECRET_KEY") + +class StripeService: + @staticmethod + def create_payment_intent(amount: float, currency: str = "usd", metadata: Dict[str, Any] = None) -> Dict[str, Any]: + try: + intent = stripe.PaymentIntent.create( + amount=int(amount * 100), # Stripe expects amount in cents + currency=currency, + metadata=metadata or {}, + automatic_payment_methods={'enabled': True} + ) + return { + "client_secret": intent.client_secret, + "payment_intent_id": intent.id, + "amount": intent.amount / 100, + "status": intent.status + } + except stripe.error.StripeError as e: + raise HTTPException(status_code=400, detail=f"Stripe error: {str(e)}") + + @staticmethod + def confirm_payment_intent(payment_intent_id: str) -> Dict[str, Any]: + try: + intent = stripe.PaymentIntent.retrieve(payment_intent_id) + return { + "payment_intent_id": intent.id, + "status": intent.status, + "amount": intent.amount / 100 + } + except stripe.error.StripeError as e: + raise HTTPException(status_code=400, detail=f"Stripe error: {str(e)}") + + @staticmethod + def cancel_payment_intent(payment_intent_id: str) -> Dict[str, Any]: + try: + intent = stripe.PaymentIntent.cancel(payment_intent_id) + return { + "payment_intent_id": intent.id, + "status": intent.status + } + except stripe.error.StripeError as e: + raise HTTPException(status_code=400, detail=f"Stripe error: {str(e)}") \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..80e64be --- /dev/null +++ b/main.py @@ -0,0 +1,45 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.api import books, inventory, orders +from app.db.session import engine + +app = FastAPI( + title="Bookstore Management API", + description="A comprehensive REST API for managing a bookstore with inventory and payment processing", + version="1.0.0", + openapi_url="/openapi.json" +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(books.router, prefix="/api/v1") +app.include_router(inventory.router, prefix="/api/v1") +app.include_router(orders.router, prefix="/api/v1") + +@app.on_event("startup") +def create_tables(): + from app.db.base import Base + Base.metadata.create_all(bind=engine) + +@app.get("/") +def root(): + return { + "title": "Bookstore Management API", + "description": "A comprehensive REST API for managing a bookstore with inventory and payment processing", + "documentation": "/docs", + "health_check": "/health" + } + +@app.get("/health") +def health_check(): + return { + "status": "healthy", + "service": "Bookstore Management API", + "version": "1.0.0" + } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3bbc148 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +sqlalchemy==2.0.23 +alembic==1.12.1 +pydantic==2.5.0 +python-multipart==0.0.6 +stripe==7.8.0 +ruff==0.1.6 +python-dotenv==1.0.0 \ No newline at end of file