diff --git a/README.md b/README.md index e8acfba..7334294 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,128 @@ -# FastAPI Application +# Simple Ecommerce API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A simple ecommerce API built with FastAPI and SQLite. + +## Features + +- User authentication (register, login) +- Product management (CRUD) +- Shopping cart functionality +- Order management +- Role-based access control (admin/regular users) + +## Tech Stack + +- Python 3.9+ +- FastAPI +- SQLAlchemy ORM +- Alembic for database migrations +- SQLite for database +- JWT for authentication + +## API Endpoints + +### Authentication + +- `POST /api/v1/auth/register`: Register a new user +- `POST /api/v1/auth/login`: Login and get access token + +### Products + +- `GET /api/v1/products`: List all products +- `GET /api/v1/products/{id}`: Get a specific product +- `POST /api/v1/products`: Create a new product (admin only) +- `PUT /api/v1/products/{id}`: Update a product (admin only) +- `DELETE /api/v1/products/{id}`: Delete a product (admin only) + +### Cart + +- `GET /api/v1/cart`: Get current user's cart +- `POST /api/v1/cart/items`: Add an item to cart +- `PUT /api/v1/cart/items/{id}`: Update cart item quantity +- `DELETE /api/v1/cart/items/{id}`: Remove an item from cart +- `DELETE /api/v1/cart`: Clear the cart + +### Orders + +- `GET /api/v1/orders`: List user's orders (or all orders for admin) +- `POST /api/v1/orders`: Create a new order from cart +- `GET /api/v1/orders/{id}`: Get a specific order with details +- `PUT /api/v1/orders/{id}/status`: Update order status (admin only) +- `DELETE /api/v1/orders/{id}`: Cancel an order + +### Users + +- `GET /api/v1/users`: List all users (admin only) +- `GET /api/v1/users/me`: Get current user details +- `PUT /api/v1/users/me`: Update current user details +- `GET /api/v1/users/{id}`: Get a specific user (admin only) +- `PUT /api/v1/users/{id}`: Update a user (admin only) +- `DELETE /api/v1/users/{id}`: Delete a user (admin only) + +## Project Structure + +``` +. +├── alembic.ini # Alembic configuration +├── app/ # Main application package +│ ├── api/ # API endpoints +│ │ └── v1/ # API version 1 +│ ├── core/ # Core functionality +│ ├── crud/ # Database CRUD operations +│ ├── db/ # Database session and models +│ ├── models/ # SQLAlchemy models +│ └── schemas/ # Pydantic schemas +├── main.py # Application entry point +├── migrations/ # Database migrations +└── requirements.txt # Project dependencies +``` + +## Getting Started + +### Prerequisites + +- Python 3.9+ + +### Installation + +1. Clone the repository: + ``` + git clone + cd simple-ecommerce-api + ``` + +2. Install dependencies: + ``` + pip install -r requirements.txt + ``` + +3. Set up environment variables: + ``` + export SECRET_KEY="your-secret-key" + export API_URL="http://localhost:8000" # Optional, default is http://localhost:8000 + ``` + +4. Run database migrations: + ``` + alembic upgrade head + ``` + +5. Start the server: + ``` + uvicorn main:app --reload + ``` + +6. Open your browser and navigate to `http://localhost:8000/docs` to see the interactive API documentation. + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| SECRET_KEY | JWT secret key | "supersecretkey" | +| API_URL | Base URL for the API | "http://localhost:8000" | +| FIRST_SUPERUSER_EMAIL | Email for initial superuser | None | +| FIRST_SUPERUSER_PASSWORD | Password for initial superuser | None | + +## License + +This project is licensed under the MIT License. \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..634a012 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,85 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat migrations/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# SQLite URL example +sqlalchemy.url = sqlite:////app/storage/db/db.sqlite + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks=black +# black.type=console_scripts +# black.entrypoint=black +# black.options=-l 79 + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/app/api/v1/api.py b/app/api/v1/api.py new file mode 100644 index 0000000..8018a5d --- /dev/null +++ b/app/api/v1/api.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from app.api.v1 import products, users, cart, orders, auth + +api_router = APIRouter() + +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(products.router, prefix="/products", tags=["products"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(cart.router, prefix="/cart", tags=["cart"]) +api_router.include_router(orders.router, prefix="/orders", tags=["orders"]) diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py new file mode 100644 index 0000000..3d95b2c --- /dev/null +++ b/app/api/v1/auth.py @@ -0,0 +1,75 @@ +from datetime import timedelta +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.security import create_access_token +from app.crud.user import user as user_crud +from app.core.deps import get_db +from app.schemas.token import Token +from app.schemas.user import User + +router = APIRouter() + + +@router.post("/login", response_model=Token) +def login_access_token( + db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends() +) -> Any: + """ + OAuth2 compatible token login, get an access token for future requests. + """ + user = user_crud.authenticate( + db, email=form_data.username, password=form_data.password + ) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + elif not user_crud.is_active(user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" + ) + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return { + "access_token": create_access_token( + user.id, expires_delta=access_token_expires + ), + "token_type": "bearer", + } + + +@router.post("/register", response_model=User) +def register_user( + *, + db: Session = Depends(get_db), + email: str, + password: str, + full_name: str = None, +) -> Any: + """ + Register a new user. + """ + user = user_crud.get_by_email(db, email=email) + if user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A user with this email already exists in the system", + ) + + from app.schemas.user import UserCreate + + user_in = UserCreate( + email=email, + password=password, + full_name=full_name, + is_superuser=False, + is_active=True, + ) + user = user_crud.create(db, obj_in=user_in) + return user diff --git a/app/api/v1/cart.py b/app/api/v1/cart.py new file mode 100644 index 0000000..288aece --- /dev/null +++ b/app/api/v1/cart.py @@ -0,0 +1,147 @@ +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.v1.deps import get_current_active_user +from app.core.deps import get_db +from app.crud.cart import cart_item as cart_item_crud +from app.crud.product import product as product_crud +from app.models.user import User +from app.schemas.cart import ( + Cart, + CartItem, + CartItemCreate, + CartItemUpdate, + CartItemWithProduct, +) +from app.schemas.product import Product + +router = APIRouter() + + +@router.get("/", response_model=Cart) +def read_cart( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Retrieve current user's cart. + """ + cart_items = cart_item_crud.get_user_cart(db, user_id=current_user.id) + + # Fetch product details for each cart item + items_with_products = [] + total = 0.0 + + for item in cart_items: + product = product_crud.get(db, id=item.product_id) + if product: + item_with_product = CartItemWithProduct.from_orm(item) + item_with_product.product = Product.from_orm(product) + items_with_products.append(item_with_product) + total += product.price * item.quantity + + return {"items": items_with_products, "total": total} + + +@router.post("/items", response_model=CartItem) +def add_cart_item( + *, + db: Session = Depends(get_db), + item_in: CartItemCreate, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Add item to cart. + """ + # Check if product exists and is in stock + product = product_crud.get(db, id=item_in.product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + + if product.stock < item_in.quantity: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Not enough stock available. Only {product.stock} items left.", + ) + + # Create or update cart item + cart_item = cart_item_crud.create_or_update( + db, user_id=current_user.id, obj_in=item_in + ) + return cart_item + + +@router.put("/items/{cart_item_id}", response_model=CartItem) +def update_cart_item( + *, + db: Session = Depends(get_db), + cart_item_id: int, + item_in: CartItemUpdate, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Update quantity of cart item. + """ + cart_item = cart_item_crud.get(db, id=cart_item_id) + if not cart_item or cart_item.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cart item not found", + ) + + # Check if product has enough stock + product = product_crud.get(db, id=cart_item.product_id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + + if product.stock < item_in.quantity: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Not enough stock available. Only {product.stock} items left.", + ) + + cart_item = cart_item_crud.update(db, db_obj=cart_item, obj_in=item_in) + return cart_item + + +@router.delete( + "/items/{cart_item_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None +) +def remove_cart_item( + *, + db: Session = Depends(get_db), + cart_item_id: int, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Remove item from cart. + """ + cart_item = cart_item_crud.get(db, id=cart_item_id) + if not cart_item or cart_item.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Cart item not found", + ) + + cart_item_crud.remove(db, id=cart_item_id) + return None + + +@router.delete("/", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +def clear_cart( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Clear all items from cart. + """ + cart_item_crud.clear_cart(db, user_id=current_user.id) + return None diff --git a/app/api/v1/deps.py b/app/api/v1/deps.py new file mode 100644 index 0000000..3f30719 --- /dev/null +++ b/app/api/v1/deps.py @@ -0,0 +1 @@ +# Re-export dependencies from core.deps diff --git a/app/api/v1/orders.py b/app/api/v1/orders.py new file mode 100644 index 0000000..b2e62d4 --- /dev/null +++ b/app/api/v1/orders.py @@ -0,0 +1,224 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.v1.deps import get_current_active_superuser, get_current_active_user +from app.core.deps import get_db +from app.crud.cart import cart_item as cart_item_crud +from app.crud.order import order as order_crud, order_item as order_item_crud +from app.crud.product import product as product_crud +from app.models.order import OrderStatus +from app.models.user import User +from app.schemas.order import Order, OrderCreate, OrderWithItems, OrderItemCreate + +router = APIRouter() + + +@router.get("/", response_model=List[Order]) +def read_orders( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Retrieve orders. + + If user is superuser, returns all orders with pagination. + Otherwise, returns only the current user's orders. + """ + if current_user.is_superuser: + orders = order_crud.get_multi(db, skip=skip, limit=limit) + else: + orders = order_crud.get_user_orders(db, user_id=current_user.id) + return orders + + +@router.post("/", response_model=Order) +def create_order( + *, + db: Session = Depends(get_db), + order_in: OrderCreate, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Create new order from user's cart. + """ + # Get user's cart + cart_items = cart_item_crud.get_user_cart(db, user_id=current_user.id) + if not cart_items: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot create order with empty cart", + ) + + # Check stock and prepare order items + order_items = [] + total_amount = 0.0 + + for cart_item in cart_items: + product = product_crud.get(db, id=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 < cart_item.quantity: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Not enough stock for product '{product.name}'. Only {product.stock} available.", + ) + + # Create order item + order_item = OrderItemCreate( + product_id=cart_item.product_id, + quantity=cart_item.quantity, + ) + order_item.unit_price = ( + product.price + ) # Add unit price from current product price + order_items.append(order_item) + + # Update total amount + total_amount += product.price * cart_item.quantity + + # Update product stock + product_crud.update_stock( + db, product_id=product.id, quantity=-cart_item.quantity + ) + + # Create order + order = order_crud.create_with_items( + db, + obj_in=order_in, + user_id=current_user.id, + items=order_items, + total_amount=total_amount, + ) + + # Clear cart + cart_item_crud.clear_cart(db, user_id=current_user.id) + + return order + + +@router.get("/{order_id}", response_model=OrderWithItems) +def read_order( + *, + db: Session = Depends(get_db), + order_id: int, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Get order by ID with its items. + """ + order = order_crud.get(db, id=order_id) + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found", + ) + + # Check if user has permission to access this order + if not current_user.is_superuser and order.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to access this order", + ) + + # Get order items + order_items = order_item_crud.get_by_order(db, order_id=order.id) + + # Fetch product details for each order item + items_with_products = [] + for item in order_items: + product = product_crud.get(db, id=item.product_id) + if product: + from app.schemas.order import OrderItemWithProduct + + item_with_product = OrderItemWithProduct.from_orm(item) + item_with_product.product = product + items_with_products.append(item_with_product) + + # Create response + from app.schemas.order import OrderWithItems + + order_with_items = OrderWithItems.from_orm(order) + order_with_items.items = items_with_products + + return order_with_items + + +@router.put("/{order_id}/status", response_model=Order) +def update_order_status( + *, + db: Session = Depends(get_db), + order_id: int, + status: OrderStatus, + current_user: User = Depends(get_current_active_superuser), +) -> Any: + """ + Update order status. + + Only superusers can update order status. + """ + order = order_crud.get(db, id=order_id) + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found", + ) + + order = order_crud.update_status(db, order_id=order_id, status=status) + return order + + +@router.delete( + "/{order_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None +) +def cancel_order( + *, + db: Session = Depends(get_db), + order_id: int, + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Cancel an order. + + Superusers can cancel any order. + Regular users can only cancel their own orders and only if the order is still pending. + """ + order = order_crud.get(db, id=order_id) + if not order: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Order not found", + ) + + # Check permissions + if not current_user.is_superuser and order.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions to cancel this order", + ) + + # Regular users can only cancel pending orders + if not current_user.is_superuser and order.status != OrderStatus.PENDING: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot cancel order that is not in 'pending' status", + ) + + # Update order status to cancelled + order_crud.update_status(db, order_id=order_id, status=OrderStatus.CANCELLED) + + # Return stock to inventory + order_items = order_item_crud.get_by_order(db, order_id=order.id) + for item in order_items: + product_crud.update_stock( + db, product_id=item.product_id, quantity=item.quantity + ) + + return None diff --git a/app/api/v1/products.py b/app/api/v1/products.py new file mode 100644 index 0000000..eda8735 --- /dev/null +++ b/app/api/v1/products.py @@ -0,0 +1,119 @@ +from typing import Any, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.v1.deps import get_current_active_superuser +from app.core.deps import get_db +from app.crud.product import product as product_crud +from app.models.user import User +from app.schemas.product import Product, ProductCreate, ProductUpdate + +router = APIRouter() + + +@router.get("/", response_model=List[Product]) +def read_products( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + search: Optional[str] = None, +) -> Any: + """ + Retrieve products. + + If search query is provided, returns products matching the search query. + Otherwise, returns all products with pagination. + """ + if search: + products = product_crud.search_products( + db, query=search, skip=skip, limit=limit + ) + else: + products = product_crud.get_multi(db, skip=skip, limit=limit) + return products + + +@router.post("/", response_model=Product) +def create_product( + *, + db: Session = Depends(get_db), + product_in: ProductCreate, + current_user: User = Depends(get_current_active_superuser), +) -> Any: + """ + Create new product. + + Only superusers can create products. + """ + product = product_crud.get_product_by_name(db, name=product_in.name) + if product: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A product with this name already exists", + ) + product = product_crud.create(db, obj_in=product_in) + return product + + +@router.get("/{id}", response_model=Product) +def read_product( + *, + db: Session = Depends(get_db), + id: int, +) -> Any: + """ + Get product by ID. + """ + product = product_crud.get(db, id=id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + return product + + +@router.put("/{id}", response_model=Product) +def update_product( + *, + db: Session = Depends(get_db), + id: int, + product_in: ProductUpdate, + current_user: User = Depends(get_current_active_superuser), +) -> Any: + """ + Update a product. + + Only superusers can update products. + """ + product = product_crud.get(db, id=id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + product = product_crud.update(db, db_obj=product, obj_in=product_in) + return product + + +@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) +def delete_product( + *, + db: Session = Depends(get_db), + id: int, + current_user: User = Depends(get_current_active_superuser), +) -> Any: + """ + Delete a product. + + Only superusers can delete products. + """ + product = product_crud.get(db, id=id) + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Product not found", + ) + product_crud.remove(db, id=id) + return None diff --git a/app/api/v1/users.py b/app/api/v1/users.py new file mode 100644 index 0000000..8179bd0 --- /dev/null +++ b/app/api/v1/users.py @@ -0,0 +1,148 @@ +from typing import Any, List + +from fastapi import APIRouter, Body, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.v1.deps import get_current_active_superuser, get_current_active_user +from app.core.deps import get_db +from app.crud.user import user as user_crud +from app.models.user import User +from app.schemas.user import User as UserSchema +from app.schemas.user import UserCreate, UserUpdate + +router = APIRouter() + + +@router.get("/", response_model=List[UserSchema]) +def read_users( + db: Session = Depends(get_db), + skip: int = 0, + limit: int = 100, + current_user: User = Depends(get_current_active_superuser), +) -> Any: + """ + Retrieve users. + + Only superusers can access this endpoint. + """ + users = user_crud.get_multi(db, skip=skip, limit=limit) + return users + + +@router.post("/", response_model=UserSchema) +def create_user( + *, + db: Session = Depends(get_db), + user_in: UserCreate, + current_user: User = Depends(get_current_active_superuser), +) -> Any: + """ + Create new user. + + Only superusers can create other users. + """ + user = user_crud.get_by_email(db, email=user_in.email) + if user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="A user with this email already exists in the system", + ) + user = user_crud.create(db, obj_in=user_in) + return user + + +@router.get("/me", response_model=UserSchema) +def read_user_me( + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Get current user. + """ + return current_user + + +@router.put("/me", response_model=UserSchema) +def update_user_me( + *, + db: Session = Depends(get_db), + full_name: str = Body(None), + password: str = Body(None), + current_user: User = Depends(get_current_active_user), +) -> Any: + """ + Update own user. + """ + user_in = UserUpdate() + if password is not None: + user_in.password = password + if full_name is not None: + user_in.full_name = full_name + user = user_crud.update(db, db_obj=current_user, obj_in=user_in) + return user + + +@router.get("/{user_id}", response_model=UserSchema) +def read_user_by_id( + user_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db), +) -> Any: + """ + Get a specific user by id. + """ + user = user_crud.get(db, id=user_id) + if user == current_user: + return user + if not user_crud.is_superuser(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The user doesn't have enough privileges", + ) + return user + + +@router.put("/{user_id}", response_model=UserSchema) +def update_user( + *, + db: Session = Depends(get_db), + user_id: int, + user_in: UserUpdate, + current_user: User = Depends(get_current_active_superuser), +) -> Any: + """ + Update a user. + + Only superusers can update other users. + """ + user = user_crud.get(db, id=user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="The user with this ID does not exist in the system", + ) + user = user_crud.update(db, db_obj=user, obj_in=user_in) + return user + + +@router.delete( + "/{user_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None +) +def delete_user( + *, + db: Session = Depends(get_db), + user_id: int, + current_user: User = Depends(get_current_active_superuser), +) -> Any: + """ + Delete a user. + + Only superusers can delete users. + """ + user = user_crud.get(db, id=user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="The user with this ID does not exist in the system", + ) + user_crud.remove(db, id=user_id) + return None diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..2816146 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,35 @@ +import os +from pathlib import Path +from typing import Optional + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + API_V1_STR: str = "/api/v1" + PROJECT_NAME: str = "Simple Ecommerce API" + PROJECT_DESCRIPTION: str = "A simple ecommerce API built with FastAPI and SQLite" + VERSION: str = "0.1.0" + + # Base API URL (without trailing slash) + API_URL: str = os.getenv("API_URL", "http://localhost:8000") + + # JWT Secret key + SECRET_KEY: str = os.getenv("SECRET_KEY", "supersecretkey") + # 60 minutes * 24 hours * 8 days = 8 days + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 + + # 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" + + # First superuser + FIRST_SUPERUSER_EMAIL: Optional[str] = os.getenv("FIRST_SUPERUSER_EMAIL") + FIRST_SUPERUSER_PASSWORD: Optional[str] = os.getenv("FIRST_SUPERUSER_PASSWORD") + + class Config: + case_sensitive = True + + +settings = Settings() diff --git a/app/core/deps.py b/app/core/deps.py new file mode 100644 index 0000000..41d821f --- /dev/null +++ b/app/core/deps.py @@ -0,0 +1,106 @@ +from typing import Generator + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.crud.user import user as user_crud +from app.db.session import SessionLocal +from app.models.user import User +from app.schemas.token import TokenPayload + +reusable_oauth2 = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login") + + +def get_db() -> Generator: + """ + Dependency function that yields a SQLAlchemy database session. + + The session is closed after the request is complete. + """ + try: + db = SessionLocal() + yield db + finally: + db.close() + + +def get_current_user( + db: Session = Depends(get_db), token: str = Depends(reusable_oauth2) +) -> User: + """ + Decode JWT token to get user identity and fetch user object from database. + + Args: + db: Database session + token: JWT token + + Returns: + User object + + Raises: + HTTPException: If token is invalid or user not found + """ + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + token_data = TokenPayload(**payload) + except (JWTError, ValidationError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + user = user_crud.get(db, id=token_data.sub) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) + return user + + +def get_current_active_user( + current_user: User = Depends(get_current_user), +) -> User: + """ + Check if current user is active. + + Args: + current_user: Current user + + Returns: + User object + + Raises: + HTTPException: If user is inactive + """ + if not user_crud.is_active(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" + ) + return current_user + + +def get_current_active_superuser( + current_user: User = Depends(get_current_user), +) -> User: + """ + Check if current user is a superuser. + + Args: + current_user: Current user + + Returns: + User object + + Raises: + HTTPException: If user is not a superuser + """ + if not user_crud.is_superuser(current_user): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The user doesn't have enough privileges", + ) + return current_user diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..daf650f --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,60 @@ +from datetime import datetime, timedelta +from typing import Any, 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: timedelta = None +) -> str: + """ + Create a new access token for a user. + + Args: + subject: The subject of the token (typically the user ID) + expires_delta: Optional expiration time for the token + + Returns: + The encoded JWT token + """ + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256") + return encoded_jwt + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """ + Verify that a plain password matches a hashed password. + + Args: + plain_password: The plain text password to check + hashed_password: The hashed password to compare against + + Returns: + True if the passwords match, False otherwise + """ + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """ + Hash a password using the configured password context. + + Args: + password: The plain text password to hash + + Returns: + The hashed password + """ + return pwd_context.hash(password) diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..d10bb5f --- /dev/null +++ b/app/crud/__init__.py @@ -0,0 +1 @@ +# Empty init file to make the directory a proper Python package diff --git a/app/crud/base.py b/app/crud/base.py new file mode 100644 index 0000000..2ca5292 --- /dev/null +++ b/app/crud/base.py @@ -0,0 +1,116 @@ +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 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). + + Args: + model: A SQLAlchemy model class + """ + self.model = model + + def get(self, db: Session, id: Any) -> Optional[ModelType]: + """ + Get a single record by ID. + + Args: + db: Database session + id: Record ID + + Returns: + The model instance or None if not found + """ + 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]: + """ + Get multiple records with pagination. + + Args: + db: Database session + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List of model instances + """ + return db.query(self.model).offset(skip).limit(limit).all() + + def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType: + """ + Create a new record. + + Args: + db: Database session + obj_in: Schema containing data for the new record + + Returns: + The created model instance + """ + obj_in_data = jsonable_encoder(obj_in) + db_obj = self.model(**obj_in_data) + 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: + """ + Update a record. + + Args: + db: Database session + db_obj: Model instance to update + obj_in: New data as schema or dict + + Returns: + The updated model instance + """ + 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: + """ + Delete a record. + + Args: + db: Database session + id: Record ID + + Returns: + The deleted model instance + """ + obj = db.query(self.model).get(id) + db.delete(obj) + db.commit() + return obj diff --git a/app/crud/cart.py b/app/crud/cart.py new file mode 100644 index 0000000..9135730 --- /dev/null +++ b/app/crud/cart.py @@ -0,0 +1,91 @@ +from typing import List, Optional + +from sqlalchemy.orm import Session + +from app.crud.base import CRUDBase +from app.models.cart import CartItem +from app.schemas.cart import CartItemCreate, CartItemUpdate + + +class CRUDCartItem(CRUDBase[CartItem, CartItemCreate, CartItemUpdate]): + def get_by_user_and_product( + self, db: Session, *, user_id: int, product_id: int + ) -> Optional[CartItem]: + """ + Get a cart item by user ID and product ID. + + Args: + db: Database session + user_id: User ID + product_id: Product ID + + Returns: + The cart item or None if not found + """ + return ( + db.query(self.model) + .filter(self.model.user_id == user_id, self.model.product_id == product_id) + .first() + ) + + def get_user_cart(self, db: Session, *, user_id: int) -> List[CartItem]: + """ + Get all cart items for a user. + + Args: + db: Database session + user_id: User ID + + Returns: + List of cart items + """ + return db.query(self.model).filter(self.model.user_id == user_id).all() + + def create_or_update( + self, db: Session, *, user_id: int, obj_in: CartItemCreate + ) -> CartItem: + """ + Create a new cart item or update the quantity if it already exists. + + Args: + db: Database session + user_id: User ID + obj_in: Cart item data + + Returns: + The created or updated cart item + """ + cart_item = self.get_by_user_and_product( + db, user_id=user_id, product_id=obj_in.product_id + ) + + if cart_item: + # Update existing cart item + cart_item.quantity += obj_in.quantity + db.add(cart_item) + db.commit() + db.refresh(cart_item) + return cart_item + else: + # Create new cart item + db_obj = CartItem( + user_id=user_id, product_id=obj_in.product_id, quantity=obj_in.quantity + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def clear_cart(self, db: Session, *, user_id: int) -> None: + """ + Remove all cart items for a user. + + Args: + db: Database session + user_id: User ID + """ + db.query(self.model).filter(self.model.user_id == user_id).delete() + db.commit() + + +cart_item = CRUDCartItem(CartItem) diff --git a/app/crud/order.py b/app/crud/order.py new file mode 100644 index 0000000..91092c4 --- /dev/null +++ b/app/crud/order.py @@ -0,0 +1,113 @@ +from typing import List, Optional + +from sqlalchemy.orm import Session + +from app.crud.base import CRUDBase +from app.models.order import Order, OrderItem, OrderStatus +from app.schemas.order import OrderCreate, OrderUpdate, OrderItemCreate + + +class CRUDOrder(CRUDBase[Order, OrderCreate, OrderUpdate]): + def get_user_orders(self, db: Session, *, user_id: int) -> List[Order]: + """ + Get all orders for a user. + + Args: + db: Database session + user_id: User ID + + Returns: + List of orders + """ + return db.query(self.model).filter(self.model.user_id == user_id).all() + + def create_with_items( + self, + db: Session, + *, + obj_in: OrderCreate, + user_id: int, + items: List[OrderItemCreate], + total_amount: float, + ) -> Order: + """ + Create a new order with order items. + + Args: + db: Database session + obj_in: Order data + user_id: User ID + items: List of order items + total_amount: Total order amount + + Returns: + The created order with items + """ + # Create order + order_data = obj_in.dict() + db_obj = Order( + **order_data, + user_id=user_id, + total_amount=total_amount, + status=OrderStatus.PENDING, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + + # Create order items + for item in items: + order_item = OrderItem( + order_id=db_obj.id, + product_id=item.product_id, + quantity=item.quantity, + unit_price=item.unit_price if hasattr(item, "unit_price") else 0, + ) + db.add(order_item) + + db.commit() + db.refresh(db_obj) + return db_obj + + def update_status( + self, db: Session, *, order_id: int, status: OrderStatus + ) -> Optional[Order]: + """ + Update the status of an order. + + Args: + db: Database session + order_id: Order ID + status: New order status + + Returns: + The updated order or None if not found + """ + order = self.get(db, id=order_id) + if not order: + return None + + order.status = status + db.add(order) + db.commit() + db.refresh(order) + return order + + +class CRUDOrderItem(CRUDBase[OrderItem, OrderItemCreate, OrderItemCreate]): + def get_by_order(self, db: Session, *, order_id: int) -> List[OrderItem]: + """ + Get all items for an order. + + Args: + db: Database session + order_id: Order ID + + Returns: + List of order items + """ + return db.query(self.model).filter(self.model.order_id == order_id).all() + + +order = CRUDOrder(Order) +order_item = CRUDOrderItem(OrderItem) diff --git a/app/crud/product.py b/app/crud/product.py new file mode 100644 index 0000000..9a247c6 --- /dev/null +++ b/app/crud/product.py @@ -0,0 +1,71 @@ +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 search_products( + self, db: Session, *, query: str, skip: int = 0, limit: int = 100 + ) -> List[Product]: + """ + Search for products by name or description. + + Args: + db: Database session + query: Search query + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List of products matching the search query + """ + search_pattern = f"%{query}%" + return ( + db.query(self.model) + .filter( + (self.model.name.ilike(search_pattern)) + | (self.model.description.ilike(search_pattern)) + ) + .offset(skip) + .limit(limit) + .all() + ) + + def get_product_by_name(self, db: Session, *, name: str) -> Optional[Product]: + """ + Get a product by its exact name. + + Args: + db: Database session + name: Product name + + Returns: + The product or None if not found + """ + return db.query(self.model).filter(self.model.name == name).first() + + def update_stock(self, db: Session, *, product_id: int, quantity: int) -> Product: + """ + Update product stock quantity. + + Args: + db: Database session + product_id: Product ID + quantity: Quantity to add (positive) or subtract (negative) + + Returns: + The updated product + """ + product = self.get(db, id=product_id) + if product: + product.stock += quantity + db.commit() + db.refresh(product) + return product + + +product = CRUDProduct(Product) diff --git a/app/crud/user.py b/app/crud/user.py new file mode 100644 index 0000000..58226c2 --- /dev/null +++ b/app/crud/user.py @@ -0,0 +1,116 @@ +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]: + """ + Get a user by email. + + Args: + db: Database session + email: User email + + Returns: + The user or None if not found + """ + return db.query(User).filter(User.email == email).first() + + def create(self, db: Session, *, obj_in: UserCreate) -> User: + """ + Create a new user. + + Args: + db: Database session + obj_in: User data including password + + Returns: + The created user + """ + db_obj = User( + email=obj_in.email, + hashed_password=get_password_hash(obj_in.password), + full_name=obj_in.full_name, + is_superuser=obj_in.is_superuser, + is_active=obj_in.is_active, + ) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def update( + self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]] + ) -> User: + """ + Update a user. + + Args: + db: Database session + db_obj: User instance to update + obj_in: New user data + + Returns: + The updated user + """ + if isinstance(obj_in, dict): + update_data = obj_in + else: + update_data = obj_in.dict(exclude_unset=True) + if "password" in update_data and update_data["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, *, email: str, password: str) -> Optional[User]: + """ + Authenticate a user. + + Args: + db: Database session + email: User email + password: User password + + Returns: + The authenticated user or None if authentication fails + """ + user = self.get_by_email(db, email=email) + if not user: + return None + if not verify_password(password, user.hashed_password): + return None + return user + + def is_active(self, user: User) -> bool: + """ + Check if a user is active. + + Args: + user: User instance + + Returns: + True if the user is active, False otherwise + """ + return user.is_active + + def is_superuser(self, user: User) -> bool: + """ + Check if a user is a superuser. + + Args: + user: User instance + + Returns: + True if the user is a superuser, False otherwise + """ + return user.is_superuser + + +user = CRUDUser(User) diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..d10bb5f --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ +# Empty init file to make the directory a proper Python package diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..860e542 --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,3 @@ +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() diff --git a/app/db/base_class.py b/app/db/base_class.py new file mode 100644 index 0000000..990c3e4 --- /dev/null +++ b/app/db/base_class.py @@ -0,0 +1,2 @@ +# Import all the models, so that Base has them before being +# imported by Alembic diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..4e72be7 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,23 @@ +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}, # Only needed for SQLite +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db(): + """ + Dependency function that yields a SQLAlchemy database session. + + The session is closed after the request is complete. + """ + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..2c6d1ba --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,5 @@ +# Import models for easier access +from app.models.user import User # noqa: F401 +from app.models.product import Product # noqa: F401 +from app.models.order import Order, OrderItem, OrderStatus # noqa: F401 +from app.models.cart import CartItem # noqa: F401 diff --git a/app/models/cart.py b/app/models/cart.py new file mode 100644 index 0000000..b681592 --- /dev/null +++ b/app/models/cart.py @@ -0,0 +1,20 @@ +from datetime import datetime +from sqlalchemy import Column, DateTime, ForeignKey, Integer +from sqlalchemy.orm import relationship + +from app.db.base import Base + + +class CartItem(Base): + __tablename__ = "cart_items" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + quantity = Column(Integer, nullable=False, default=1) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + user = relationship("User", back_populates="cart_items") + product = relationship("Product", back_populates="cart_items") diff --git a/app/models/order.py b/app/models/order.py new file mode 100644 index 0000000..b54cbf0 --- /dev/null +++ b/app/models/order.py @@ -0,0 +1,47 @@ +from datetime import datetime +from enum import Enum as PyEnum +from sqlalchemy import Column, DateTime, Enum, Float, ForeignKey, Integer, String +from sqlalchemy.orm import relationship + +from app.db.base import Base + + +class OrderStatus(str, PyEnum): + PENDING = "pending" + PAID = "paid" + SHIPPED = "shipped" + DELIVERED = "delivered" + CANCELLED = "cancelled" + + +class Order(Base): + __tablename__ = "orders" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + status = Column(Enum(OrderStatus), default=OrderStatus.PENDING, nullable=False) + total_amount = Column(Float, nullable=False) + shipping_address = Column(String, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + user = relationship("User", back_populates="orders") + items = relationship( + "OrderItem", back_populates="order", cascade="all, delete-orphan" + ) + + +class OrderItem(Base): + __tablename__ = "order_items" + + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) + product_id = Column(Integer, ForeignKey("products.id"), nullable=False) + quantity = Column(Integer, nullable=False) + unit_price = Column(Float, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + order = relationship("Order", back_populates="items") + product = relationship("Product", back_populates="order_items") diff --git a/app/models/product.py b/app/models/product.py new file mode 100644 index 0000000..5e13afb --- /dev/null +++ b/app/models/product.py @@ -0,0 +1,22 @@ +from datetime import datetime +from sqlalchemy import Column, DateTime, Float, Integer, String, Text +from sqlalchemy.orm import relationship + +from app.db.base import Base + + +class Product(Base): + __tablename__ = "products" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True, nullable=False) + description = Column(Text, nullable=True) + price = Column(Float, nullable=False) + stock = Column(Integer, default=0, nullable=False) + image_url = Column(String, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + order_items = relationship("OrderItem", back_populates="product") + cart_items = relationship("CartItem", back_populates="product") diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..a48e273 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,22 @@ +from datetime import datetime +from sqlalchemy import Boolean, Column, DateTime, Integer, String +from sqlalchemy.orm import relationship + +from app.db.base import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) + full_name = Column(String, index=True) + is_active = Column(Boolean, default=True) + is_superuser = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + orders = relationship("Order", back_populates="user") + cart_items = relationship("CartItem", back_populates="user") diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..d10bb5f --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ +# Empty init file to make the directory a proper Python package diff --git a/app/schemas/cart.py b/app/schemas/cart.py new file mode 100644 index 0000000..932f964 --- /dev/null +++ b/app/schemas/cart.py @@ -0,0 +1,51 @@ +from datetime import datetime +from typing import List + +from pydantic import BaseModel, Field + +from app.schemas.product import Product + + +# Shared properties +class CartItemBase(BaseModel): + product_id: int + quantity: int = Field(..., gt=0) + + +# Properties to receive on cart item creation +class CartItemCreate(CartItemBase): + pass + + +# Properties to receive on cart item update +class CartItemUpdate(BaseModel): + quantity: int = Field(..., gt=0) + + +class CartItemInDBBase(CartItemBase): + id: int + user_id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# Additional properties to return via API +class CartItem(CartItemInDBBase): + pass + + +# Additional properties stored in DB +class CartItemInDB(CartItemInDBBase): + pass + + +class CartItemWithProduct(CartItem): + product: Product + + +class Cart(BaseModel): + items: List[CartItemWithProduct] + total: float diff --git a/app/schemas/order.py b/app/schemas/order.py new file mode 100644 index 0000000..4cb8712 --- /dev/null +++ b/app/schemas/order.py @@ -0,0 +1,79 @@ +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + +from app.models.order import OrderStatus +from app.schemas.product import Product + + +# Shared properties +class OrderItemBase(BaseModel): + product_id: int + quantity: int = Field(..., gt=0) + + +# Properties to receive on order item creation +class OrderItemCreate(OrderItemBase): + pass + + +# Additional properties stored in DB for order item +class OrderItemInDBBase(OrderItemBase): + id: int + order_id: int + unit_price: float + created_at: datetime + + class Config: + from_attributes = True + + +# Additional properties to return via API +class OrderItem(OrderItemInDBBase): + pass + + +class OrderItemWithProduct(OrderItem): + product: Product + + +# Shared properties for order +class OrderBase(BaseModel): + shipping_address: Optional[str] = None + + +# Properties to receive on order creation +class OrderCreate(OrderBase): + shipping_address: str + + +# Properties to receive on order update +class OrderUpdate(BaseModel): + status: OrderStatus + + +class OrderInDBBase(OrderBase): + id: int + user_id: int + status: OrderStatus + total_amount: float + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# Additional properties to return via API +class Order(OrderInDBBase): + pass + + +# Additional properties stored in DB +class OrderInDB(OrderInDBBase): + pass + + +class OrderWithItems(Order): + items: List[OrderItemWithProduct] diff --git a/app/schemas/product.py b/app/schemas/product.py new file mode 100644 index 0000000..9a341d4 --- /dev/null +++ b/app/schemas/product.py @@ -0,0 +1,44 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +# Shared properties +class ProductBase(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + price: Optional[float] = None + stock: Optional[int] = None + image_url: Optional[str] = None + + +# Properties to receive on product creation +class ProductCreate(ProductBase): + name: str + price: float = Field(..., gt=0) + stock: int = Field(..., ge=0) + + +# Properties to receive on product update +class ProductUpdate(ProductBase): + pass + + +class ProductInDBBase(ProductBase): + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# Additional properties to return via API +class Product(ProductInDBBase): + pass + + +# Additional properties stored in DB +class ProductInDB(ProductInDBBase): + pass diff --git a/app/schemas/token.py b/app/schemas/token.py new file mode 100644 index 0000000..a53652e --- /dev/null +++ b/app/schemas/token.py @@ -0,0 +1,13 @@ +from typing import Optional + +from pydantic import BaseModel + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenPayload(BaseModel): + sub: Optional[int] = None + is_superuser: bool = False diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..221c0a0 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,42 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, EmailStr + + +# Shared properties +class UserBase(BaseModel): + email: Optional[EmailStr] = None + is_active: Optional[bool] = True + is_superuser: bool = False + full_name: Optional[str] = None + + +# Properties to receive via API on creation +class UserCreate(UserBase): + email: EmailStr + password: str + + +# Properties to receive via API on update +class UserUpdate(UserBase): + password: Optional[str] = None + + +class UserInDBBase(UserBase): + id: Optional[int] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# Additional properties to return via API +class User(UserInDBBase): + pass + + +# Additional properties stored in DB +class UserInDB(UserInDBBase): + hashed_password: str diff --git a/main.py b/main.py new file mode 100644 index 0000000..849d58e --- /dev/null +++ b/main.py @@ -0,0 +1,47 @@ +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1.api import api_router +from app.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, + description=settings.PROJECT_DESCRIPTION, + version=settings.VERSION, + openapi_url="/openapi.json", + docs_url="/docs", + redoc_url="/redoc", +) + +# Set up CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include API router +app.include_router(api_router, prefix=settings.API_V1_STR) + + +@app.get("/") +async def root(): + """Root endpoint returning basic info about the API.""" + return { + "title": settings.PROJECT_NAME, + "docs": f"{settings.API_URL}/docs", + "health": f"{settings.API_URL}/health", + } + + +@app.get("/health", status_code=200) +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy"} + + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..3542e0e --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration with SQLite. \ No newline at end of file diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..d10bb5f --- /dev/null +++ b/migrations/__init__.py @@ -0,0 +1 @@ +# Empty init file to make the directory a proper Python package diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..bf47444 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,82 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# Import models for alembic +from app.db.base_class import Base # noqa: E402 + +# 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 +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(): + """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: + is_sqlite = connection.dialect.name == "sqlite" + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=is_sqlite, # Key configuration for SQLite + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1e4564e --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/migrations/versions/8e3ca9ec8ac8_initial_migration.py b/migrations/versions/8e3ca9ec8ac8_initial_migration.py new file mode 100644 index 0000000..b9c25e7 --- /dev/null +++ b/migrations/versions/8e3ca9ec8ac8_initial_migration.py @@ -0,0 +1,140 @@ +"""Initial migration + +Revision ID: 8e3ca9ec8ac8 +Revises: +Create Date: 2023-08-07 12:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "8e3ca9ec8ac8" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # Create users table + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("hashed_password", sa.String(), nullable=False), + sa.Column("full_name", sa.String(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=True, default=True), + sa.Column("is_superuser", sa.Boolean(), nullable=True, default=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + op.create_index(op.f("ix_users_full_name"), "users", ["full_name"], unique=False) + op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False) + + # Create products table + op.create_table( + "products", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("price", sa.Float(), nullable=False), + sa.Column("stock", sa.Integer(), nullable=False, default=0), + sa.Column("image_url", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_products_id"), "products", ["id"], unique=False) + op.create_index(op.f("ix_products_name"), "products", ["name"], unique=False) + + # Create orders table + op.create_table( + "orders", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column( + "status", + sa.Enum( + "pending", + "paid", + "shipped", + "delivered", + "cancelled", + name="orderstatus", + ), + nullable=False, + default="pending", + ), + sa.Column("total_amount", sa.Float(), nullable=False), + sa.Column("shipping_address", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_orders_id"), "orders", ["id"], unique=False) + + # Create cart_items table + op.create_table( + "cart_items", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("product_id", sa.Integer(), nullable=False), + sa.Column("quantity", sa.Integer(), nullable=False, default=1), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["product_id"], + ["products.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_cart_items_id"), "cart_items", ["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("product_id", sa.Integer(), nullable=False), + sa.Column("quantity", sa.Integer(), nullable=False), + sa.Column("unit_price", sa.Float(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["order_id"], + ["orders.id"], + ), + sa.ForeignKeyConstraint( + ["product_id"], + ["products.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_order_items_id"), "order_items", ["id"], unique=False) + + +def downgrade(): + op.drop_index(op.f("ix_order_items_id"), table_name="order_items") + op.drop_table("order_items") + op.drop_index(op.f("ix_cart_items_id"), table_name="cart_items") + op.drop_table("cart_items") + op.drop_index(op.f("ix_orders_id"), table_name="orders") + op.drop_table("orders") + 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") + op.drop_index(op.f("ix_users_id"), table_name="users") + op.drop_index(op.f("ix_users_full_name"), table_name="users") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_table("users") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c228883 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi>=0.103.1 +uvicorn>=0.23.2 +sqlalchemy>=2.0.20 +alembic>=1.12.0 +pydantic>=2.3.0 +pydantic-settings>=2.0.3 +python-jose>=3.3.0 +passlib>=1.7.4 +python-multipart>=0.0.6 +email-validator>=2.0.0 +bcrypt>=4.0.1 +ruff>=0.0.287 \ No newline at end of file