diff --git a/README.md b/README.md index e8acfba..f4e40a3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,111 @@ -# FastAPI Application +# Simple Cart System -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A simple shopping cart system API built with FastAPI and SQLite. + +## Features + +- Product management (CRUD operations) +- Shopping cart functionality +- User-specific carts +- Stock management + +## Getting Started + +### Prerequisites + +- Python 3.8 or higher +- pip (Python package manager) + +### Installation + +1. Clone the repository: + +```bash +git clone +cd simplecartsystem +``` + +2. Install the dependencies: + +```bash +pip install -r requirements.txt +``` + +3. Run database migrations: + +```bash +alembic upgrade head +``` + +4. Start the application: + +```bash +uvicorn main:app --reload +``` + +The API will be available at http://localhost:8000. + +## API Documentation + +Interactive API documentation is available at: +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## API Endpoints + +### Health Check + +- `GET /api/v1/health` - Check API and database health + +### Products + +- `GET /api/v1/products` - List all products (with pagination) +- `GET /api/v1/products/{product_id}` - Get a specific product +- `POST /api/v1/products` - Create a new product +- `PUT /api/v1/products/{product_id}` - Update a product +- `DELETE /api/v1/products/{product_id}` - Delete a product + +### Cart + +- `GET /api/v1/cart` - View current cart +- `POST /api/v1/cart/items` - Add item to cart +- `PUT /api/v1/cart/items/{item_id}` - Update cart item quantity +- `DELETE /api/v1/cart/items/{item_id}` - Remove item from cart +- `DELETE /api/v1/cart` - Clear cart + +## Cart Usage Example + +### Add Product to Cart + +```bash +curl -X POST "http://localhost:8000/api/v1/cart/items" \ + -H "Content-Type: application/json" \ + -H "user-id: user123" \ + -d '{ + "product_id": 1, + "quantity": 2 + }' +``` + +### View Cart + +```bash +curl -X GET "http://localhost:8000/api/v1/cart" \ + -H "user-id: user123" +``` + +## Database Schema + +The system uses SQLite with the following main tables: + +- `products`: Stores product information +- `carts`: Stores user cart information +- `cart_items`: Stores items in user carts + +## Technology Stack + +- [FastAPI](https://fastapi.tiangolo.com/) - Web framework +- [SQLAlchemy](https://www.sqlalchemy.org/) - ORM +- [Alembic](https://alembic.sqlalchemy.org/) - Database migrations +- [Pydantic](https://pydantic-docs.helpmanual.io/) - Data validation +- [SQLite](https://www.sqlite.org/) - Database \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..6752c48 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,110 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..4918861 --- /dev/null +++ b/alembic/README @@ -0,0 +1,11 @@ +Generic single-database configuration with SQLite. + +To run the migrations: +``` +alembic upgrade head +``` + +To generate a new migration (after modifying models): +``` +alembic revision --autogenerate -m "description of changes" +``` \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..514d74a --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,83 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context +from app.models.base_model import Base +# Import all models that should be included in migrations +from app.models.product import Product # noqa +from app.models.cart import Cart, CartItem # noqa + +# This is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + is_sqlite = connection.dialect.name == "sqlite" + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=is_sqlite, # Key configuration for SQLite + ) + + 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/initial_migration.py b/alembic/versions/initial_migration.py new file mode 100644 index 0000000..dcd6e5a --- /dev/null +++ b/alembic/versions/initial_migration.py @@ -0,0 +1,78 @@ +"""initial migration + +Revision ID: 00001 +Revises: +Create Date: 2023-11-15 + +""" +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() -> None: + # Create products table + op.create_table( + 'products', + sa.Column('id', 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), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('stock', sa.Integer(), nullable=False), + sa.Column('image_url', sa.String(length=512), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_products_id'), 'products', ['id'], unique=False) + op.create_index(op.f('ix_products_name'), 'products', ['name'], unique=False) + + # Create carts table + op.create_table( + 'carts', + sa.Column('id', 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), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('user_id', sa.String(length=255), nullable=False), + sa.Column('is_active', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_carts_id'), 'carts', ['id'], unique=False) + op.create_index(op.f('ix_carts_user_id'), 'carts', ['user_id'], unique=False) + + # Create cart_items table + op.create_table( + 'cart_items', + sa.Column('id', 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), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('cart_id', sa.Integer(), nullable=False), + sa.Column('product_id', sa.Integer(), nullable=False), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.Column('unit_price', sa.Float(), nullable=False), + sa.ForeignKeyConstraint(['cart_id'], ['carts.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('cart_id', 'product_id', name='uix_cart_product') + ) + op.create_index(op.f('ix_cart_items_id'), 'cart_items', ['id'], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f('ix_cart_items_id'), table_name='cart_items') + op.drop_table('cart_items') + + op.drop_index(op.f('ix_carts_user_id'), table_name='carts') + op.drop_index(op.f('ix_carts_id'), table_name='carts') + op.drop_table('carts') + + op.drop_index(op.f('ix_products_name'), table_name='products') + op.drop_index(op.f('ix_products_id'), table_name='products') + op.drop_table('products') \ 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/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/cart.py b/app/api/v1/cart.py new file mode 100644 index 0000000..9245828 --- /dev/null +++ b/app/api/v1/cart.py @@ -0,0 +1,109 @@ +from fastapi import APIRouter, Depends, HTTPException, Header, status +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.cart import CartItemCreate, CartItemUpdate, CartDetail, CartItemResponse +from app.schemas.common import DataResponse, ResponseBase +from app.services import cart_service + +router = APIRouter(prefix="/cart", tags=["cart"]) + + +@router.get("", response_model=DataResponse[CartDetail]) +def get_cart( + user_id: str = Header(..., description="User ID for cart identification"), + db: Session = Depends(get_db) +): + """ + Get the current user's cart with all items. + """ + cart = cart_service.get_active_cart(db, user_id) + cart_detail = cart_service.get_cart_detail(db, cart.id) + + return { + "success": True, + "message": "Cart retrieved successfully", + "data": cart_detail + } + + +@router.post("/items", response_model=DataResponse[CartItemResponse], status_code=status.HTTP_201_CREATED) +def add_to_cart( + item_data: CartItemCreate, + user_id: str = Header(..., description="User ID for cart identification"), + db: Session = Depends(get_db) +): + """ + Add a product to the user's cart. + """ + cart_item, is_new = cart_service.add_item_to_cart(db, user_id, item_data) + + status_message = "Item added to cart successfully" if is_new else "Item quantity updated successfully" + + return { + "success": True, + "message": status_message, + "data": cart_item + } + + +@router.put("/items/{item_id}", response_model=DataResponse[CartItemResponse]) +def update_cart_item( + item_id: int, + item_data: CartItemUpdate, + user_id: str = Header(..., description="User ID for cart identification"), + db: Session = Depends(get_db) +): + """ + Update the quantity of an item in the user's cart. + """ + cart_item = cart_service.update_cart_item(db, user_id, item_id, item_data) + if not cart_item: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Cart item with ID {item_id} not found in user's cart" + ) + + return { + "success": True, + "message": "Cart item updated successfully", + "data": cart_item + } + + +@router.delete("/items/{item_id}", response_model=ResponseBase) +def remove_cart_item( + item_id: int, + user_id: str = Header(..., description="User ID for cart identification"), + db: Session = Depends(get_db) +): + """ + Remove an item from the user's cart. + """ + success = cart_service.remove_cart_item(db, user_id, item_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Cart item with ID {item_id} not found in user's cart" + ) + + return { + "success": True, + "message": "Item removed from cart successfully" + } + + +@router.delete("", response_model=ResponseBase) +def clear_cart( + user_id: str = Header(..., description="User ID for cart identification"), + db: Session = Depends(get_db) +): + """ + Remove all items from the user's cart. + """ + cart_service.clear_cart(db, user_id) + + return { + "success": True, + "message": "Cart cleared successfully" + } \ No newline at end of file diff --git a/app/api/v1/health.py b/app/api/v1/health.py new file mode 100644 index 0000000..ff1d978 --- /dev/null +++ b/app/api/v1/health.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.db.session import get_db + +router = APIRouter(prefix="/health", tags=["health"]) + + +@router.get("") +def health_check(db: Session = Depends(get_db)): + """ + Health check endpoint to verify the API is running correctly. + """ + # Check database connection + try: + # Execute a simple query to check the database connection + db.execute("SELECT 1") + db_status = "healthy" + except Exception: + db_status = "unhealthy" + + return { + "status": "ok", + "database": db_status, + } \ No newline at end of file diff --git a/app/api/v1/products.py b/app/api/v1/products.py new file mode 100644 index 0000000..fac0aa5 --- /dev/null +++ b/app/api/v1/products.py @@ -0,0 +1,105 @@ +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.schemas.product import Product, ProductCreate, ProductUpdate +from app.schemas.common import PaginatedResponse, DataResponse +from app.services import product_service + +router = APIRouter(prefix="/products", tags=["products"]) + + +@router.get("", response_model=PaginatedResponse[Product]) +def get_products( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=100), + is_active: Optional[bool] = None, + db: Session = Depends(get_db) +): + """ + Get a list of products with pagination and optional filtering. + """ + products = product_service.get_products(db, skip=skip, limit=limit, is_active=is_active) + total = product_service.get_product_count(db, is_active=is_active) + + return { + "success": True, + "message": "Products retrieved successfully", + "data": products, + "total": total, + "page": skip // limit + 1 if limit > 0 else 1, + "size": limit, + "pages": (total + limit - 1) // limit if limit > 0 else 1 + } + + +@router.get("/{product_id}", response_model=DataResponse[Product]) +def get_product(product_id: int, db: Session = Depends(get_db)): + """ + Get a specific product by ID. + """ + product = product_service.get_product_by_id(db, product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product with ID {product_id} not found" + ) + + return { + "success": True, + "message": "Product retrieved successfully", + "data": product + } + + +@router.post("", response_model=DataResponse[Product], status_code=status.HTTP_201_CREATED) +def create_product(product_data: ProductCreate, db: Session = Depends(get_db)): + """ + Create a new product. + """ + product = product_service.create_product(db, product_data) + + return { + "success": True, + "message": "Product created successfully", + "data": product + } + + +@router.put("/{product_id}", response_model=DataResponse[Product]) +def update_product( + product_id: int, + product_data: ProductUpdate, + db: Session = Depends(get_db) +): + """ + Update an existing product. + """ + product = product_service.update_product(db, product_id, product_data) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product with ID {product_id} not found" + ) + + return { + "success": True, + "message": "Product updated successfully", + "data": product + } + + +@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +def delete_product(product_id: int, db: Session = Depends(get_db)): + """ + Delete a product. + """ + success = product_service.delete_product(db, product_id) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product with ID {product_id} not found" + ) + + return None \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..fc724be --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,27 @@ +from typing import List, Union +from pydantic import AnyHttpUrl, validator +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + API_V1_STR: str = "/api/v1" + PROJECT_NAME: str = "Simple Cart System" + PROJECT_DESCRIPTION: str = "A simple cart system API built with FastAPI and SQLite" + PROJECT_VERSION: str = "0.1.0" + + # CORS + BACKEND_CORS_ORIGINS: List[Union[str, AnyHttpUrl]] = ["http://localhost", "http://localhost:8000"] + + @validator("BACKEND_CORS_ORIGINS", pre=True) + def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]: + if isinstance(v, str) and not v.startswith("["): + return [i.strip() for i in v.split(",")] + elif isinstance(v, (list, str)): + return v + raise ValueError(v) + + class Config: + case_sensitive = True + + +settings = Settings() \ 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/session.py b/app/db/session.py new file mode 100644 index 0000000..593e593 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,24 @@ +from pathlib import Path +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +# Set up database directory +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) + +# Create a dependency +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..4057a60 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,6 @@ +# Re-export models for easier imports elsewhere +from app.models.base_model import Base as Base +from app.models.product import Product as Product +from app.models.cart import Cart as Cart, CartItem as CartItem + +__all__ = ["Base", "Product", "Cart", "CartItem"] \ No newline at end of file diff --git a/app/models/base_model.py b/app/models/base_model.py new file mode 100644 index 0000000..1e4d40e --- /dev/null +++ b/app/models/base_model.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, Integer, DateTime +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.sql import func + +Base = declarative_base() + + +class BaseModel(Base): + """Base model for all models to inherit from.""" + + __abstract__ = True + + id = Column(Integer, primary_key=True, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) \ No newline at end of file diff --git a/app/models/cart.py b/app/models/cart.py new file mode 100644 index 0000000..2cfecb2 --- /dev/null +++ b/app/models/cart.py @@ -0,0 +1,31 @@ +from sqlalchemy import Column, String, Float, Integer, ForeignKey, UniqueConstraint +from sqlalchemy.orm import relationship +from app.models.base_model import BaseModel + + +class Cart(BaseModel): + """Model for shopping cart.""" + + __tablename__ = "carts" + + user_id = Column(String(255), nullable=False, index=True) + is_active = Column(Integer, default=1, nullable=False) + + items = relationship("CartItem", back_populates="cart", cascade="all, delete-orphan") + + +class CartItem(BaseModel): + """Model for items in shopping cart.""" + + __tablename__ = "cart_items" + __table_args__ = ( + UniqueConstraint("cart_id", "product_id", name="uix_cart_product"), + ) + + cart_id = Column(Integer, ForeignKey("carts.id", ondelete="CASCADE"), nullable=False) + product_id = Column(Integer, ForeignKey("products.id", ondelete="CASCADE"), nullable=False) + quantity = Column(Integer, nullable=False, default=1) + unit_price = Column(Float, nullable=False) # Stores the price at the time of adding to cart + + cart = relationship("Cart", back_populates="items") + product = relationship("Product") \ No newline at end of file diff --git a/app/models/product.py b/app/models/product.py new file mode 100644 index 0000000..55ad9f7 --- /dev/null +++ b/app/models/product.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, String, Float, Integer, Text, Boolean +from app.models.base_model import BaseModel + + +class Product(BaseModel): + """Model for product items.""" + + __tablename__ = "products" + + name = Column(String(255), nullable=False, index=True) + description = Column(Text, nullable=True) + price = Column(Float, nullable=False) + stock = Column(Integer, nullable=False, default=0) + image_url = Column(String(512), nullable=True) + is_active = Column(Boolean, default=True, nullable=False) \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..5ebb65e --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,31 @@ +# Re-export schemas for easier imports elsewhere +from app.schemas.common import ( + ResponseBase as ResponseBase, + DataResponse as DataResponse, + PaginatedResponse as PaginatedResponse, + PaginationParams as PaginationParams +) +from app.schemas.product import ( + Product as Product, + ProductCreate as ProductCreate, + ProductUpdate as ProductUpdate, + ProductInDB as ProductInDB +) +from app.schemas.cart import ( + CartBase as CartBase, + CartCreate as CartCreate, + CartResponse as CartResponse, + CartDetail as CartDetail, + CartItemBase as CartItemBase, + CartItemCreate as CartItemCreate, + CartItemUpdate as CartItemUpdate, + CartItemResponse as CartItemResponse, + CartItemDetail as CartItemDetail +) + +__all__ = [ + "ResponseBase", "DataResponse", "PaginatedResponse", "PaginationParams", + "Product", "ProductCreate", "ProductUpdate", "ProductInDB", + "CartBase", "CartCreate", "CartResponse", "CartDetail", + "CartItemBase", "CartItemCreate", "CartItemUpdate", "CartItemResponse", "CartItemDetail" +] \ No newline at end of file diff --git a/app/schemas/cart.py b/app/schemas/cart.py new file mode 100644 index 0000000..a418b01 --- /dev/null +++ b/app/schemas/cart.py @@ -0,0 +1,77 @@ +from typing import List +from pydantic import BaseModel, Field, validator + + +class CartItemBase(BaseModel): + """Base schema for cart item data.""" + product_id: int = Field(..., title="Product ID") + quantity: int = Field(..., title="Quantity", ge=1) + + @validator('quantity') + def validate_quantity(cls, v): + if v < 1: + raise ValueError('Quantity must be at least 1') + return v + + +class CartItemCreate(CartItemBase): + """Schema for adding an item to a cart.""" + pass + + +class CartItemUpdate(BaseModel): + """Schema for updating a cart item.""" + quantity: int = Field(..., title="New quantity", ge=1) + + @validator('quantity') + def validate_quantity(cls, v): + if v < 1: + raise ValueError('Quantity must be at least 1') + return v + + +class CartItemResponse(CartItemBase): + """Schema for cart item data in API responses.""" + id: int + unit_price: float + + class Config: + orm_mode = True + + +class CartItemDetail(CartItemResponse): + """Schema for detailed cart item data including product details.""" + product_name: str + subtotal: float + + class Config: + orm_mode = True + + +class CartBase(BaseModel): + """Base schema for cart data.""" + user_id: str = Field(..., title="User ID") + is_active: int = Field(1, title="Cart status") + + +class CartCreate(CartBase): + """Schema for creating a new cart.""" + pass + + +class CartResponse(CartBase): + """Schema for cart data in API responses.""" + id: int + created_at: str + + class Config: + orm_mode = True + + +class CartDetail(CartResponse): + """Schema for detailed cart data including items.""" + items: List[CartItemDetail] = [] + total: float + + class Config: + orm_mode = True \ No newline at end of file diff --git a/app/schemas/common.py b/app/schemas/common.py new file mode 100644 index 0000000..22858ce --- /dev/null +++ b/app/schemas/common.py @@ -0,0 +1,31 @@ +from typing import Generic, TypeVar, List, Optional +from pydantic import BaseModel + + +T = TypeVar('T') + + +class PaginationParams(BaseModel): + """Schema for pagination parameters.""" + skip: int = 0 + limit: int = 100 + + +class ResponseBase(BaseModel): + """Base schema for API responses.""" + success: bool + message: str + + +class DataResponse(ResponseBase, Generic[T]): + """Schema for API responses with data.""" + data: Optional[T] = None + + +class PaginatedResponse(ResponseBase, Generic[T]): + """Schema for paginated API responses.""" + data: List[T] + total: int + page: int + size: int + pages: int \ No newline at end of file diff --git a/app/schemas/product.py b/app/schemas/product.py new file mode 100644 index 0000000..0d217c0 --- /dev/null +++ b/app/schemas/product.py @@ -0,0 +1,56 @@ +from typing import Optional +from pydantic import BaseModel, Field, validator + + +class ProductBase(BaseModel): + """Base schema for product data.""" + name: str = Field(..., title="Product name", min_length=1, max_length=255) + description: Optional[str] = Field(None, title="Product description") + price: float = Field(..., title="Product price", ge=0.01) + stock: int = Field(..., title="Available quantity", ge=0) + image_url: Optional[str] = Field(None, title="Product image URL") + is_active: bool = Field(True, title="Product availability status") + + @validator('price') + def validate_price(cls, v): + if v < 0: + raise ValueError('Price must be positive') + return round(v, 2) + + +class ProductCreate(ProductBase): + """Schema for creating a new product.""" + pass + + +class ProductUpdate(BaseModel): + """Schema for updating an existing product.""" + name: Optional[str] = Field(None, title="Product name", min_length=1, max_length=255) + description: Optional[str] = Field(None, title="Product description") + price: Optional[float] = Field(None, title="Product price", ge=0.01) + stock: Optional[int] = Field(None, title="Available quantity", ge=0) + image_url: Optional[str] = Field(None, title="Product image URL") + is_active: Optional[bool] = Field(None, title="Product availability status") + + @validator('price') + def validate_price(cls, v): + if v is not None and v < 0: + raise ValueError('Price must be positive') + if v is not None: + return round(v, 2) + return v + + +class ProductInDB(ProductBase): + """Schema for product data retrieved from the database.""" + id: int + created_at: str + updated_at: str + + class Config: + orm_mode = True + + +class Product(ProductInDB): + """Schema for product data returned in API responses.""" + pass \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..26ccf59 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,26 @@ +# Re-export service functions for easier imports elsewhere +from app.services.product_service import ( + get_products as get_products, + get_product_by_id as get_product_by_id, + create_product as create_product, + update_product as update_product, + delete_product as delete_product, + get_product_count as get_product_count +) + +from app.services.cart_service import ( + get_active_cart as get_active_cart, + get_cart_with_items as get_cart_with_items, + get_cart_detail as get_cart_detail, + add_item_to_cart as add_item_to_cart, + update_cart_item as update_cart_item, + remove_cart_item as remove_cart_item, + clear_cart as clear_cart +) + +__all__ = [ + "get_products", "get_product_by_id", "create_product", "update_product", + "delete_product", "get_product_count", "get_active_cart", "get_cart_with_items", + "get_cart_detail", "add_item_to_cart", "update_cart_item", "remove_cart_item", + "clear_cart" +] \ No newline at end of file diff --git a/app/services/cart_service.py b/app/services/cart_service.py new file mode 100644 index 0000000..bbee807 --- /dev/null +++ b/app/services/cart_service.py @@ -0,0 +1,213 @@ +from typing import Optional, Dict, Any, Tuple +from sqlalchemy.orm import Session, joinedload +from fastapi import HTTPException, status + +from app.models.cart import Cart, CartItem +from app.schemas.cart import CartItemCreate, CartItemUpdate +from app.services import product_service + + +def get_active_cart(db: Session, user_id: str) -> Optional[Cart]: + """ + Get the active cart for a user, or create one if it doesn't exist. + """ + cart = db.query(Cart).filter( + Cart.user_id == user_id, + Cart.is_active == 1 + ).first() + + if not cart: + cart = Cart(user_id=user_id, is_active=1) + db.add(cart) + db.commit() + db.refresh(cart) + + return cart + + +def get_cart_with_items(db: Session, cart_id: int) -> Optional[Cart]: + """ + Get a cart by ID with all its items. + """ + return db.query(Cart).options( + joinedload(Cart.items) + ).filter(Cart.id == cart_id).first() + + +def get_cart_detail(db: Session, cart_id: int) -> Dict[str, Any]: + """ + Get detailed cart information including items and total. + """ + cart = get_cart_with_items(db, cart_id) + if not cart: + return None + + items_with_details = [] + total = 0.0 + + for item in cart.items: + product = product_service.get_product_by_id(db, item.product_id) + if product: + subtotal = item.quantity * item.unit_price + total += subtotal + items_with_details.append({ + "id": item.id, + "product_id": item.product_id, + "product_name": product.name, + "quantity": item.quantity, + "unit_price": item.unit_price, + "subtotal": subtotal + }) + + return { + "id": cart.id, + "user_id": cart.user_id, + "is_active": cart.is_active, + "created_at": cart.created_at, + "items": items_with_details, + "total": round(total, 2) + } + + +def add_item_to_cart( + db: Session, + user_id: str, + item_data: CartItemCreate +) -> Tuple[CartItem, bool]: + """ + Add an item to a user's active cart. Returns the cart item and a boolean + indicating if it's a new item (True) or an updated existing item (False). + """ + # Get or create active cart + cart = get_active_cart(db, user_id) + + # Check if product exists and is available + product = product_service.get_product_by_id(db, item_data.product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product with ID {item_data.product_id} not found" + ) + + if not product.is_active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Product with ID {item_data.product_id} is not available" + ) + + if product.stock < item_data.quantity: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Not enough stock available. Requested: {item_data.quantity}, Available: {product.stock}" + ) + + # Check if the item already exists in the cart + cart_item = db.query(CartItem).filter( + CartItem.cart_id == cart.id, + CartItem.product_id == item_data.product_id + ).first() + + is_new = False + + if cart_item: + # Update existing item + new_quantity = cart_item.quantity + item_data.quantity + if new_quantity > product.stock: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Not enough stock available. Requested total: {new_quantity}, Available: {product.stock}" + ) + cart_item.quantity = new_quantity + else: + # Create new item + is_new = True + cart_item = CartItem( + cart_id=cart.id, + product_id=item_data.product_id, + quantity=item_data.quantity, + unit_price=product.price + ) + db.add(cart_item) + + db.commit() + db.refresh(cart_item) + return cart_item, is_new + + +def update_cart_item( + db: Session, + user_id: str, + item_id: int, + item_data: CartItemUpdate +) -> Optional[CartItem]: + """ + Update the quantity of an item in a user's active cart. + """ + # Get active cart + cart = get_active_cart(db, user_id) + + # Find the cart item + cart_item = db.query(CartItem).filter( + CartItem.id == item_id, + CartItem.cart_id == cart.id + ).first() + + if not cart_item: + return None + + # Check product stock + product = product_service.get_product_by_id(db, cart_item.product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product with ID {cart_item.product_id} not found" + ) + + if product.stock < item_data.quantity: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Not enough stock available. Requested: {item_data.quantity}, Available: {product.stock}" + ) + + # Update quantity + cart_item.quantity = item_data.quantity + + db.commit() + db.refresh(cart_item) + return cart_item + + +def remove_cart_item(db: Session, user_id: str, item_id: int) -> bool: + """ + Remove an item from a user's active cart. + """ + # Get active cart + cart = get_active_cart(db, user_id) + + # Find the cart item + cart_item = db.query(CartItem).filter( + CartItem.id == item_id, + CartItem.cart_id == cart.id + ).first() + + if not cart_item: + return False + + # Remove item + db.delete(cart_item) + db.commit() + return True + + +def clear_cart(db: Session, user_id: str) -> bool: + """ + Remove all items from a user's active cart. + """ + # Get active cart + cart = get_active_cart(db, user_id) + + # Delete all cart items + deleted = db.query(CartItem).filter(CartItem.cart_id == cart.id).delete() + db.commit() + + return deleted > 0 \ No newline at end of file diff --git a/app/services/product_service.py b/app/services/product_service.py new file mode 100644 index 0000000..a65f4ae --- /dev/null +++ b/app/services/product_service.py @@ -0,0 +1,87 @@ +from typing import List, Optional +from sqlalchemy.orm import Session + +from app.models.product import Product +from app.schemas.product import ProductCreate, ProductUpdate + + +def get_products( + db: Session, + skip: int = 0, + limit: int = 100, + is_active: Optional[bool] = None +) -> List[Product]: + """ + Get a list of products with pagination and optional filtering. + """ + query = db.query(Product) + + if is_active is not None: + query = query.filter(Product.is_active == is_active) + + return query.offset(skip).limit(limit).all() + + +def get_product_by_id(db: Session, product_id: int) -> Optional[Product]: + """ + Get a product by its ID. + """ + return db.query(Product).filter(Product.id == product_id).first() + + +def create_product(db: Session, product_data: ProductCreate) -> Product: + """ + Create a new product. + """ + db_product = Product(**product_data.dict()) + db.add(db_product) + db.commit() + db.refresh(db_product) + return db_product + + +def update_product( + db: Session, + product_id: int, + product_data: ProductUpdate +) -> Optional[Product]: + """ + Update an existing product. + """ + db_product = get_product_by_id(db, product_id) + if not db_product: + return None + + # Update only provided fields + update_data = product_data.dict(exclude_unset=True) + for key, value in update_data.items(): + setattr(db_product, key, value) + + db.commit() + db.refresh(db_product) + return db_product + + +def delete_product(db: Session, product_id: int) -> bool: + """ + Delete a product by its ID. + """ + db_product = get_product_by_id(db, product_id) + if not db_product: + return False + + db.delete(db_product) + db.commit() + return True + + +def get_product_count(db: Session, is_active: Optional[bool] = None) -> int: + """ + Get the total count of products with optional filtering. + """ + query = db.query(Product) + + if is_active is not None: + query = query.filter(Product.is_active == is_active) + + return query.count() \ No newline at end of file diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..19e0e17 --- /dev/null +++ b/main.py @@ -0,0 +1,46 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1 import health, products, cart +from app.core.config import settings +from app.db.session import engine +from app.models import base_model + +# Create tables if they don't exist +base_model.Base.metadata.create_all(bind=engine) + +app = FastAPI( + title=settings.PROJECT_NAME, + description=settings.PROJECT_DESCRIPTION, + version=settings.PROJECT_VERSION, + openapi_url=f"{settings.API_V1_STR}/openapi.json", + docs_url="/docs", + redoc_url="/redoc", +) + +# Set up CORS +if settings.BACKEND_CORS_ORIGINS: + app.add_middleware( + CORSMiddleware, + allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + +# Include routers +app.include_router(health.router, prefix=settings.API_V1_STR) +app.include_router(products.router, prefix=settings.API_V1_STR) +app.include_router(cart.router, prefix=settings.API_V1_STR) + + +@app.get("/") +async def root(): + return { + "message": "Welcome to Simple Cart System API", + "documentation": "/docs", + } + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e35489a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi>=0.104.1 +uvicorn>=0.24.0 +sqlalchemy>=2.0.23 +alembic>=1.12.1 +pydantic>=2.4.2 +pydantic-settings>=2.0.3 +python-multipart>=0.0.6 +ruff>=0.1.5 \ No newline at end of file