From 4458f5320ba587d08c13409da0550e15f6ee4b24 Mon Sep 17 00:00:00 2001 From: Automated Action Date: Tue, 13 May 2025 22:46:42 +0000 Subject: [PATCH] Build e-commerce API with FastAPI and SQLite - Implemented user authentication with JWT tokens - Created product management endpoints - Added shopping cart functionality - Implemented order management system - Setup database models with SQLAlchemy - Created alembic migrations - Added health check endpoint generated with BackendIM... (backend.im) --- README.md | 90 ++++++- alembic.ini | 84 ++++++ alembic/__init__.py | 0 alembic/env.py | 77 ++++++ alembic/script.py.mako | 24 ++ .../20250513_000001_initial_migration.py | 112 ++++++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/deps.py | 53 ++++ app/api/endpoints/__init__.py | 0 app/api/endpoints/auth.py | 65 +++++ app/api/endpoints/cart.py | 161 ++++++++++++ app/api/endpoints/orders.py | 244 ++++++++++++++++++ app/api/endpoints/products.py | 131 ++++++++++ app/api/endpoints/users.py | 129 +++++++++ app/api/routes.py | 10 + app/core/__init__.py | 0 app/core/config.py | 36 +++ app/core/security.py | 31 +++ app/db/__init__.py | 0 app/db/session.py | 22 ++ app/models/__init__.py | 4 + app/models/base.py | 15 ++ app/models/cart.py | 15 ++ app/models/order.py | 37 +++ app/models/product.py | 18 ++ app/models/user.py | 17 ++ app/schemas/__init__.py | 5 + app/schemas/cart.py | 34 +++ app/schemas/order.py | 62 +++++ app/schemas/product.py | 36 +++ app/schemas/token.py | 12 + app/schemas/user.py | 36 +++ app/utils/__init__.py | 0 main.py | 33 +++ requirements.txt | 11 + 36 files changed, 1602 insertions(+), 2 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/__init__.py create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/20250513_000001_initial_migration.py create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/deps.py create mode 100644 app/api/endpoints/__init__.py create mode 100644 app/api/endpoints/auth.py create mode 100644 app/api/endpoints/cart.py create mode 100644 app/api/endpoints/orders.py create mode 100644 app/api/endpoints/products.py create mode 100644 app/api/endpoints/users.py create mode 100644 app/api/routes.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/core/security.py create mode 100644 app/db/__init__.py create mode 100644 app/db/session.py create mode 100644 app/models/__init__.py create mode 100644 app/models/base.py create mode 100644 app/models/cart.py create mode 100644 app/models/order.py create mode 100644 app/models/product.py create mode 100644 app/models/user.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/cart.py create mode 100644 app/schemas/order.py create mode 100644 app/schemas/product.py create mode 100644 app/schemas/token.py create mode 100644 app/schemas/user.py create mode 100644 app/utils/__init__.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/README.md b/README.md index e8acfba..4b3f01f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,89 @@ -# FastAPI Application +# E-Commerce API -This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. +A RESTful API for e-commerce applications built with FastAPI and SQLite. This API provides endpoints for user authentication, product management, shopping cart functionality, and order processing. + +## Features + +- User authentication with JWT tokens +- Product catalog with filtering capabilities +- Shopping cart functionality +- Order management and processing +- Admin routes for managing products and orders +- Data persistence with SQLite +- Alembic migrations for database version control + +## API Endpoints + +### Authentication +- `POST /api/v1/auth/register` - Register a new user +- `POST /api/v1/auth/login` - Login and get access token + +### Users +- `GET /api/v1/users/me` - Get current user information +- `PUT /api/v1/users/me` - Update current user information +- `GET /api/v1/users/{user_id}` - Get user by ID (admin or self only) +- `GET /api/v1/users/` - List all users (admin only) +- `DELETE /api/v1/users/{user_id}` - Delete a user (admin only) + +### Products +- `GET /api/v1/products/` - List all active products with filtering options +- `POST /api/v1/products/` - Create a new product (admin only) +- `GET /api/v1/products/{product_id}` - Get product details +- `PUT /api/v1/products/{product_id}` - Update a product (admin only) +- `DELETE /api/v1/products/{product_id}` - Soft delete a product (admin only) + +### Cart +- `GET /api/v1/cart/` - Get current user's cart items +- `POST /api/v1/cart/` - Add item to cart +- `PUT /api/v1/cart/{cart_item_id}` - Update cart item quantity +- `DELETE /api/v1/cart/{cart_item_id}` - Remove item from cart +- `DELETE /api/v1/cart/` - Clear 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 or specified items +- `GET /api/v1/orders/{order_id}` - Get order details with items +- `PUT /api/v1/orders/{order_id}/status` - Update order status (admin only) +- `DELETE /api/v1/orders/{order_id}` - Cancel a pending order + +### Health Check +- `GET /health` - Check API health status + +## Getting Started + +### Prerequisites +- Python 3.8 or higher + +### Installation + +1. Clone the repository + ``` + git clone + ``` + +2. Install dependencies + ``` + pip install -r requirements.txt + ``` + +3. Initialize the database + ``` + alembic upgrade head + ``` + +4. Run the server + ``` + uvicorn main:app --reload + ``` + +## API Documentation +When the server is running, you can access the interactive API documentation at: +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## Environment Variables + +You can customize the following settings in the `app/core/config.py` file: +- `SECRET_KEY`: Secret key for JWT token generation +- `ACCESS_TOKEN_EXPIRE_MINUTES`: Token expiration time +- `BACKEND_CORS_ORIGINS`: CORS origin settings \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..ad55a3b --- /dev/null +++ b/alembic.ini @@ -0,0 +1,84 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = 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/alembic/__init__.py b/alembic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..40a51d3 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,77 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +from app.models.base import Base +from app.models import User, Product, CartItem, Order, OrderItem +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: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1e4564e --- /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(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/alembic/versions/20250513_000001_initial_migration.py b/alembic/versions/20250513_000001_initial_migration.py new file mode 100644 index 0000000..f4f56c2 --- /dev/null +++ b/alembic/versions/20250513_000001_initial_migration.py @@ -0,0 +1,112 @@ +"""initial migration + +Revision ID: 20250513_000001 +Revises: +Create Date: 2025-05-13 00:00:01.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20250513_000001' +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('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_admin', sa.Boolean(), nullable=True, default=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + 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('is_active', sa.Boolean(), nullable=True, default=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), 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('total_amount', sa.Float(), nullable=False), + sa.Column('status', sa.String(), nullable=False, default='pending'), + sa.Column('shipping_address', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_orders_id'), 'orders', ['id'], unique=False) + + # Create order_items table + op.create_table( + 'order_items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('order_id', sa.Integer(), nullable=False), + sa.Column('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(timezone=True), server_default=sa.func.now(), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), 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) + + # 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(timezone=True), server_default=sa.func.now(), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), 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) + + +def downgrade(): + op.drop_index(op.f('ix_cart_items_id'), table_name='cart_items') + op.drop_table('cart_items') + op.drop_index(op.f('ix_order_items_id'), table_name='order_items') + op.drop_table('order_items') + op.drop_index(op.f('ix_orders_id'), table_name='orders') + op.drop_table('orders') + op.drop_index(op.f('ix_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_email'), table_name='users') + op.drop_index(op.f('ix_users_id'), table_name='users') + op.drop_table('users') \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..84c2bb5 --- /dev/null +++ b/app/api/deps.py @@ -0,0 +1,53 @@ +from typing import Generator, Optional + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import jwt, JWTError +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app import models, schemas +from app.core import security +from app.core.config import settings +from app.db.session import get_db + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login") + + +def get_current_user( + db: Session = Depends(get_db), token: str = Depends(oauth2_scheme) +) -> models.User: + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + token_data = schemas.TokenPayload(**payload) + except (JWTError, ValidationError): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials", + ) + user = db.query(models.User).filter(models.User.id == token_data.sub).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return user + + +def get_current_active_user( + current_user: models.User = Depends(get_current_user), +) -> models.User: + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + + +def get_current_active_admin( + current_user: models.User = Depends(get_current_user), +) -> models.User: + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions" + ) + return current_user \ No newline at end of file diff --git a/app/api/endpoints/__init__.py b/app/api/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/endpoints/auth.py b/app/api/endpoints/auth.py new file mode 100644 index 0000000..0359edb --- /dev/null +++ b/app/api/endpoints/auth.py @@ -0,0 +1,65 @@ +from datetime import timedelta +from typing import Any + +from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session + +from app import models, schemas +from app.api import deps +from app.core import security +from app.core.config import settings + +router = APIRouter() + + +@router.post("/login", response_model=schemas.Token) +def login_access_token( + db: Session = Depends(deps.get_db), form_data: OAuth2PasswordRequestForm = Depends() +) -> Any: + """ + OAuth2 compatible token login, get an access token for future requests + """ + user = db.query(models.User).filter(models.User.email == form_data.username).first() + if not user or not security.verify_password(form_data.password, user.password): + raise HTTPException(status_code=400, detail="Incorrect email or password") + elif not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return { + "access_token": security.create_access_token( + user.id, expires_delta=access_token_expires + ), + "token_type": "bearer", + } + + +@router.post("/register", response_model=schemas.User) +def register_user( + *, + db: Session = Depends(deps.get_db), + user_in: schemas.UserCreate, +) -> Any: + """ + Register a new user + """ + user = db.query(models.User).filter(models.User.email == user_in.email).first() + if user: + raise HTTPException( + status_code=400, + detail="A user with this email already exists.", + ) + + hashed_password = security.get_password_hash(user_in.password) + db_user = models.User( + email=user_in.email, + password=hashed_password, + full_name=user_in.full_name, + is_active=user_in.is_active, + is_admin=user_in.is_admin, + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user \ No newline at end of file diff --git a/app/api/endpoints/cart.py b/app/api/endpoints/cart.py new file mode 100644 index 0000000..945957a --- /dev/null +++ b/app/api/endpoints/cart.py @@ -0,0 +1,161 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session, joinedload + +from app import models, schemas +from app.api import deps + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.CartItemWithProduct]) +def read_cart_items( + db: Session = Depends(deps.get_db), + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve cart items for the current user. + """ + cart_items = ( + db.query(models.CartItem) + .filter(models.CartItem.user_id == current_user.id) + .options(joinedload(models.CartItem.product)) + .all() + ) + return cart_items + + +@router.post("/", response_model=schemas.CartItem) +def add_cart_item( + *, + db: Session = Depends(deps.get_db), + cart_item_in: schemas.CartItemCreate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Add item to cart. + """ + # Check if product exists and is active + product = db.query(models.Product).filter( + models.Product.id == cart_item_in.product_id, + models.Product.is_active == True + ).first() + if not product: + raise HTTPException( + status_code=404, + detail="Product not found or inactive", + ) + + # Check if product is in stock + if product.stock < cart_item_in.quantity: + raise HTTPException( + status_code=400, + detail=f"Not enough stock available. Only {product.stock} items left.", + ) + + # Check if item already in cart, update quantity if it is + existing_item = db.query(models.CartItem).filter( + models.CartItem.user_id == current_user.id, + models.CartItem.product_id == cart_item_in.product_id + ).first() + + if existing_item: + existing_item.quantity += cart_item_in.quantity + db.add(existing_item) + db.commit() + db.refresh(existing_item) + return existing_item + + # Create new cart item + cart_item = models.CartItem( + user_id=current_user.id, + product_id=cart_item_in.product_id, + quantity=cart_item_in.quantity, + ) + db.add(cart_item) + db.commit() + db.refresh(cart_item) + return cart_item + + +@router.put("/{cart_item_id}", response_model=schemas.CartItem) +def update_cart_item( + *, + db: Session = Depends(deps.get_db), + cart_item_id: int, + cart_item_in: schemas.CartItemUpdate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Update quantity of cart item. + """ + cart_item = db.query(models.CartItem).filter( + models.CartItem.id == cart_item_id, + models.CartItem.user_id == current_user.id + ).first() + + if not cart_item: + raise HTTPException( + status_code=404, + detail="Cart item not found", + ) + + # Check if product has enough stock + product = db.query(models.Product).filter(models.Product.id == cart_item.product_id).first() + if not product or product.stock < cart_item_in.quantity: + raise HTTPException( + status_code=400, + detail=f"Not enough stock available. Only {product.stock if product else 0} items left.", + ) + + cart_item.quantity = cart_item_in.quantity + db.add(cart_item) + db.commit() + db.refresh(cart_item) + return cart_item + + +@router.delete("/{cart_item_id}", response_model=schemas.CartItem) +def delete_cart_item( + *, + db: Session = Depends(deps.get_db), + cart_item_id: int, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Delete cart item. + """ + cart_item = db.query(models.CartItem).filter( + models.CartItem.id == cart_item_id, + models.CartItem.user_id == current_user.id + ).first() + + if not cart_item: + raise HTTPException( + status_code=404, + detail="Cart item not found", + ) + + db.delete(cart_item) + db.commit() + return cart_item + + +@router.delete("/", response_model=List[schemas.CartItem]) +def clear_cart( + db: Session = Depends(deps.get_db), + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Clear all items from cart. + """ + cart_items = db.query(models.CartItem).filter( + models.CartItem.user_id == current_user.id + ).all() + + for item in cart_items: + db.delete(item) + + db.commit() + return cart_items \ No newline at end of file diff --git a/app/api/endpoints/orders.py b/app/api/endpoints/orders.py new file mode 100644 index 0000000..284c386 --- /dev/null +++ b/app/api/endpoints/orders.py @@ -0,0 +1,244 @@ +from typing import Any, List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session, joinedload + +from app import models, schemas +from app.api import deps +from app.models.order import OrderStatus + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.Order]) +def read_orders( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Retrieve orders for current user. + """ + if current_user.is_admin: + orders = db.query(models.Order).offset(skip).limit(limit).all() + else: + orders = ( + db.query(models.Order) + .filter(models.Order.user_id == current_user.id) + .offset(skip) + .limit(limit) + .all() + ) + return orders + + +@router.post("/", response_model=schemas.Order) +def create_order( + *, + db: Session = Depends(deps.get_db), + order_in: schemas.OrderCreate, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Create new order. Can create from provided items or from user's cart. + """ + # If no items provided, use cart + if not order_in.items or len(order_in.items) == 0: + cart_items = ( + db.query(models.CartItem) + .filter(models.CartItem.user_id == current_user.id) + .all() + ) + + if not cart_items or len(cart_items) == 0: + raise HTTPException( + status_code=400, + detail="Cart is empty and no items provided", + ) + + # Calculate total and create order + total_amount = 0.0 + order_items = [] + + for cart_item in cart_items: + product = db.query(models.Product).filter(models.Product.id == cart_item.product_id).first() + + if not product or not product.is_active: + raise HTTPException( + status_code=400, + detail=f"Product with id {cart_item.product_id} not found or is inactive", + ) + + if product.stock < cart_item.quantity: + raise HTTPException( + status_code=400, + detail=f"Not enough stock for product '{product.name}'. Requested: {cart_item.quantity}, Available: {product.stock}", + ) + + item_total = product.price * cart_item.quantity + total_amount += item_total + + order_items.append( + models.OrderItem( + product_id=product.id, + quantity=cart_item.quantity, + unit_price=product.price, + ) + ) + + # Update product stock + product.stock -= cart_item.quantity + db.add(product) + + # Remove from cart + db.delete(cart_item) + else: + # Create order from provided items + total_amount = 0.0 + order_items = [] + + for item in order_in.items: + product = db.query(models.Product).filter( + models.Product.id == item.product_id, + models.Product.is_active == True + ).first() + + if not product: + raise HTTPException( + status_code=400, + detail=f"Product with id {item.product_id} not found or is inactive", + ) + + if product.stock < item.quantity: + raise HTTPException( + status_code=400, + detail=f"Not enough stock for product '{product.name}'. Requested: {item.quantity}, Available: {product.stock}", + ) + + item_total = product.price * item.quantity + total_amount += item_total + + order_items.append( + models.OrderItem( + product_id=product.id, + quantity=item.quantity, + unit_price=product.price, + ) + ) + + # Update product stock + product.stock -= item.quantity + db.add(product) + + # Create order + order = models.Order( + user_id=current_user.id, + total_amount=total_amount, + status=OrderStatus.PENDING, + shipping_address=order_in.shipping_address, + ) + + db.add(order) + db.commit() + db.refresh(order) + + # Add order items + for item in order_items: + item.order_id = order.id + db.add(item) + + db.commit() + db.refresh(order) + return order + + +@router.get("/{order_id}", response_model=schemas.OrderWithItems) +def read_order( + *, + db: Session = Depends(deps.get_db), + order_id: int, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get a specific order by id. + """ + order = ( + db.query(models.Order) + .options(joinedload(models.Order.items).joinedload(models.OrderItem.product)) + .filter(models.Order.id == order_id) + .first() + ) + + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + if order.user_id != current_user.id and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Not enough permissions") + + return order + + +@router.put("/{order_id}/status", response_model=schemas.Order) +def update_order_status( + *, + db: Session = Depends(deps.get_db), + order_id: int, + status: OrderStatus, + current_user: models.User = Depends(deps.get_current_active_admin), +) -> Any: + """ + Update an order's status. Admin only. + """ + order = db.query(models.Order).filter(models.Order.id == order_id).first() + + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + order.status = status + db.add(order) + db.commit() + db.refresh(order) + return order + + +@router.delete("/{order_id}", response_model=schemas.Order) +def cancel_order( + *, + db: Session = Depends(deps.get_db), + order_id: int, + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Cancel an order. Only possible if status is pending. + """ + order = db.query(models.Order).filter( + models.Order.id == order_id + ).first() + + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + if order.user_id != current_user.id and not current_user.is_admin: + raise HTTPException(status_code=403, detail="Not enough permissions") + + if order.status != OrderStatus.PENDING: + raise HTTPException( + status_code=400, + detail=f"Cannot cancel order with status '{order.status}'. Only pending orders can be cancelled." + ) + + # Return items to stock + order_items = db.query(models.OrderItem).filter(models.OrderItem.order_id == order.id).all() + + for item in order_items: + product = db.query(models.Product).filter(models.Product.id == item.product_id).first() + if product: + product.stock += item.quantity + db.add(product) + + order.status = OrderStatus.CANCELLED + db.add(order) + db.commit() + db.refresh(order) + return order \ No newline at end of file diff --git a/app/api/endpoints/products.py b/app/api/endpoints/products.py new file mode 100644 index 0000000..cec456d --- /dev/null +++ b/app/api/endpoints/products.py @@ -0,0 +1,131 @@ +from typing import Any, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app import models, schemas +from app.api import deps + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.Product]) +def read_products( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + name: Optional[str] = None, + min_price: Optional[float] = None, + max_price: Optional[float] = None, +) -> Any: + """ + Retrieve products with optional filtering. + """ + query = db.query(models.Product).filter(models.Product.is_active == True) + + if name: + query = query.filter(models.Product.name.ilike(f"%{name}%")) + if min_price is not None: + query = query.filter(models.Product.price >= min_price) + if max_price is not None: + query = query.filter(models.Product.price <= max_price) + + products = query.offset(skip).limit(limit).all() + return products + + +@router.post("/", response_model=schemas.Product) +def create_product( + *, + db: Session = Depends(deps.get_db), + product_in: schemas.ProductCreate, + current_user: models.User = Depends(deps.get_current_active_admin), +) -> Any: + """ + Create new product. + """ + product = models.Product( + name=product_in.name, + description=product_in.description, + price=product_in.price, + stock=product_in.stock, + image_url=product_in.image_url, + is_active=product_in.is_active, + ) + db.add(product) + db.commit() + db.refresh(product) + return product + + +@router.get("/{product_id}", response_model=schemas.Product) +def read_product( + *, + db: Session = Depends(deps.get_db), + product_id: int, +) -> Any: + """ + Get product by ID. + """ + product = db.query(models.Product).filter( + models.Product.id == product_id, + models.Product.is_active == True + ).first() + if not product: + raise HTTPException( + status_code=404, detail="Product not found" + ) + return product + + +@router.put("/{product_id}", response_model=schemas.Product) +def update_product( + *, + db: Session = Depends(deps.get_db), + product_id: int, + product_in: schemas.ProductUpdate, + current_user: models.User = Depends(deps.get_current_active_admin), +) -> Any: + """ + Update a product. + """ + product = db.query(models.Product).filter(models.Product.id == product_id).first() + if not product: + raise HTTPException( + status_code=404, + detail="Product not found", + ) + + update_data = product_in.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(product, field, value) + + db.add(product) + db.commit() + db.refresh(product) + return product + + +@router.delete("/{product_id}", response_model=schemas.Product) +def delete_product( + *, + db: Session = Depends(deps.get_db), + product_id: int, + current_user: models.User = Depends(deps.get_current_active_admin), +) -> Any: + """ + Delete a product. + """ + product = db.query(models.Product).filter(models.Product.id == product_id).first() + if not product: + raise HTTPException( + status_code=404, + detail="Product not found", + ) + + # Soft delete by marking as inactive + product.is_active = False + db.add(product) + db.commit() + db.refresh(product) + return product \ No newline at end of file diff --git a/app/api/endpoints/users.py b/app/api/endpoints/users.py new file mode 100644 index 0000000..950aa44 --- /dev/null +++ b/app/api/endpoints/users.py @@ -0,0 +1,129 @@ +from typing import Any, List + +from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi.encoders import jsonable_encoder +from sqlalchemy.orm import Session + +from app import models, schemas +from app.api import deps +from app.core.security import get_password_hash + +router = APIRouter() + + +@router.get("/", response_model=List[schemas.User]) +def read_users( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, + current_user: models.User = Depends(deps.get_current_active_admin), +) -> Any: + """ + Retrieve users. + """ + users = db.query(models.User).offset(skip).limit(limit).all() + return users + + +@router.get("/me", response_model=schemas.User) +def read_user_me( + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Get current user. + """ + return current_user + + +@router.put("/me", response_model=schemas.User) +def update_user_me( + *, + db: Session = Depends(deps.get_db), + password: str = Body(None), + full_name: str = Body(None), + email: str = Body(None), + current_user: models.User = Depends(deps.get_current_active_user), +) -> Any: + """ + Update own user. + """ + current_user_data = jsonable_encoder(current_user) + user_in = schemas.UserUpdate(**current_user_data) + if password is not None: + user_in.password = password + if full_name is not None: + user_in.full_name = full_name + if email is not None: + user_in.email = email + + if user_in.password: + hashed_password = get_password_hash(user_in.password) + current_user.password = hashed_password + if user_in.full_name: + current_user.full_name = user_in.full_name + if user_in.email: + # Check if email already exists + user = db.query(models.User).filter( + models.User.email == user_in.email, + models.User.id != current_user.id + ).first() + if user: + raise HTTPException( + status_code=400, + detail="A user with this email already exists.", + ) + current_user.email = user_in.email + + db.add(current_user) + db.commit() + db.refresh(current_user) + return current_user + + +@router.get("/{user_id}", response_model=schemas.User) +def read_user_by_id( + user_id: int, + current_user: models.User = Depends(deps.get_current_active_user), + db: Session = Depends(deps.get_db), +) -> Any: + """ + Get a specific user by id. + """ + user = db.query(models.User).filter(models.User.id == user_id).first() + if user == current_user: + return user + if not current_user.is_admin: + raise HTTPException( + status_code=400, detail="The user doesn't have enough privileges" + ) + if not user: + raise HTTPException( + status_code=404, + detail="The user with this id does not exist in the system", + ) + return user + + +@router.delete("/{user_id}", response_model=schemas.User) +def delete_user( + user_id: int, + db: Session = Depends(deps.get_db), + current_user: models.User = Depends(deps.get_current_active_admin), +) -> Any: + """ + Delete a user. + """ + user = db.query(models.User).filter(models.User.id == user_id).first() + if not user: + raise HTTPException( + status_code=404, + detail="The user with this id does not exist in the system", + ) + if user.id == current_user.id: + raise HTTPException( + status_code=400, + detail="Users cannot delete themselves", + ) + db.delete(user) + db.commit() + return user \ No newline at end of file diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..f10cc3d --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter + +from app.api.endpoints import users, products, cart, orders, auth + +api_router = APIRouter() +api_router.include_router(auth.router, prefix="/auth", tags=["authentication"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(products.router, prefix="/products", tags=["products"]) +api_router.include_router(cart.router, prefix="/cart", tags=["cart"]) +api_router.include_router(orders.router, prefix="/orders", tags=["orders"]) \ 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..01af9d9 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,36 @@ +from typing import Any, Dict, List, Optional, Union +from pathlib import Path + +from pydantic import AnyHttpUrl, BaseSettings, validator + + +class Settings(BaseSettings): + API_V1_STR: str = "/api/v1" + PROJECT_NAME: str = "ECommerce API" + + # CORS Settings + BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = [] + + @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) + + # Security Settings + SECRET_KEY: str = "supersecretkey" # Change in production + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + + # Database Settings + DB_DIR = Path("/app") / "storage" / "db" + DB_DIR.mkdir(parents=True, exist_ok=True) + SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite" + + class Config: + case_sensitive = True + + +settings = Settings() \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..6feba2f --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,31 @@ +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: + 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=settings.ALGORITHM) + return encoded_jwt + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) \ 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..d078137 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,22 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +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) + +Base = declarative_base() + + +# Dependency for routes +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..6201624 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,4 @@ +from app.models.user import User +from app.models.product import Product +from app.models.cart import CartItem +from app.models.order import Order, OrderItem, OrderStatus \ No newline at end of file diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..e4d4f91 --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, DateTime +from sqlalchemy.sql import func +from app.db.session import Base + + +class TimestampMixin: + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class BaseModel(Base, TimestampMixin): + __abstract__ = True + id = Column(Integer, primary_key=True, index=True) \ No newline at end of file diff --git a/app/models/cart.py b/app/models/cart.py new file mode 100644 index 0000000..c50c14c --- /dev/null +++ b/app/models/cart.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, Integer, ForeignKey +from sqlalchemy.orm import relationship + +from app.models.base import BaseModel + + +class CartItem(BaseModel): + __tablename__ = "cart_items" + + 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) + + user = relationship("User", back_populates="cart_items") + product = relationship("Product", back_populates="cart_items") \ No newline at end of file diff --git a/app/models/order.py b/app/models/order.py new file mode 100644 index 0000000..799942b --- /dev/null +++ b/app/models/order.py @@ -0,0 +1,37 @@ +from sqlalchemy import Column, Integer, ForeignKey, Float, String, Enum +from sqlalchemy.orm import relationship +import enum + +from app.models.base import BaseModel + + +class OrderStatus(str, enum.Enum): + PENDING = "pending" + PAID = "paid" + SHIPPED = "shipped" + DELIVERED = "delivered" + CANCELLED = "cancelled" + + +class Order(BaseModel): + __tablename__ = "orders" + + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + total_amount = Column(Float, nullable=False) + status = Column(String, default=OrderStatus.PENDING, nullable=False) + shipping_address = Column(String, nullable=True) + + user = relationship("User", back_populates="orders") + items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan") + + +class OrderItem(BaseModel): + __tablename__ = "order_items" + + 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) + + order = relationship("Order", back_populates="items") + product = relationship("Product", back_populates="order_items") \ No newline at end of file diff --git a/app/models/product.py b/app/models/product.py new file mode 100644 index 0000000..8087945 --- /dev/null +++ b/app/models/product.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, String, Float, Integer, Text, Boolean +from sqlalchemy.orm import relationship + +from app.models.base import BaseModel + + +class Product(BaseModel): + __tablename__ = "products" + + name = Column(String, index=True, nullable=False) + description = Column(Text, nullable=True) + price = Column(Float, nullable=False) + stock = Column(Integer, nullable=False, default=0) + image_url = Column(String, nullable=True) + is_active = Column(Boolean, default=True) + + order_items = relationship("OrderItem", back_populates="product") + cart_items = relationship("CartItem", back_populates="product") \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..f58c38c --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,17 @@ +from sqlalchemy import Boolean, Column, String +from sqlalchemy.orm import relationship + +from app.models.base import BaseModel + + +class User(BaseModel): + __tablename__ = "users" + + email = Column(String, unique=True, index=True, nullable=False) + password = Column(String, nullable=False) + full_name = Column(String, nullable=True) + is_active = Column(Boolean, default=True) + is_admin = Column(Boolean, default=False) + + orders = relationship("Order", back_populates="user", cascade="all, delete-orphan") + cart_items = relationship("CartItem", back_populates="user", cascade="all, delete-orphan") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..92f0c73 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,5 @@ +from app.schemas.user import User, UserCreate, UserInDB, UserUpdate +from app.schemas.product import Product, ProductCreate, ProductUpdate +from app.schemas.cart import CartItem, CartItemCreate, CartItemUpdate, CartItemWithProduct +from app.schemas.order import Order, OrderCreate, OrderItem, OrderWithItems +from app.schemas.token import Token, TokenPayload \ No newline at end of file diff --git a/app/schemas/cart.py b/app/schemas/cart.py new file mode 100644 index 0000000..eb628bc --- /dev/null +++ b/app/schemas/cart.py @@ -0,0 +1,34 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + +from app.schemas.product import Product + + +class CartItemBase(BaseModel): + product_id: int + quantity: int = Field(..., gt=0) + + +class CartItemCreate(CartItemBase): + pass + + +class CartItemUpdate(BaseModel): + quantity: int = Field(..., gt=0) + + +class CartItemInDBBase(CartItemBase): + id: int + user_id: int + + class Config: + orm_mode = True + + +class CartItem(CartItemInDBBase): + pass + + +class CartItemWithProduct(CartItemInDBBase): + product: Product \ No newline at end of file diff --git a/app/schemas/order.py b/app/schemas/order.py new file mode 100644 index 0000000..e6b8d49 --- /dev/null +++ b/app/schemas/order.py @@ -0,0 +1,62 @@ +from typing import List, Optional +from datetime import datetime + +from pydantic import BaseModel, Field + +from app.models.order import OrderStatus +from app.schemas.product import Product + + +class OrderItemBase(BaseModel): + product_id: int + quantity: int = Field(..., gt=0) + unit_price: float = Field(..., gt=0) + + +class OrderItemCreate(OrderItemBase): + pass + + +class OrderItemInDBBase(OrderItemBase): + id: int + order_id: int + + class Config: + orm_mode = True + + +class OrderItem(OrderItemInDBBase): + pass + + +class OrderItemWithProduct(OrderItemInDBBase): + product: Product + + +class OrderBase(BaseModel): + total_amount: float = Field(..., gt=0) + status: OrderStatus = OrderStatus.PENDING + shipping_address: Optional[str] = None + + +class OrderCreate(BaseModel): + shipping_address: Optional[str] = None + items: List[OrderItemCreate] + + +class OrderInDBBase(OrderBase): + id: int + user_id: int + created_at: datetime + updated_at: datetime + + class Config: + orm_mode = True + + +class Order(OrderInDBBase): + pass + + +class OrderWithItems(OrderInDBBase): + items: List[OrderItemWithProduct] \ No newline at end of file diff --git a/app/schemas/product.py b/app/schemas/product.py new file mode 100644 index 0000000..f5c96e1 --- /dev/null +++ b/app/schemas/product.py @@ -0,0 +1,36 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class ProductBase(BaseModel): + name: str + description: Optional[str] = None + price: float = Field(..., gt=0) + stock: int = Field(..., ge=0) + image_url: Optional[str] = None + is_active: bool = True + + +class ProductCreate(ProductBase): + pass + + +class ProductUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + price: Optional[float] = Field(None, gt=0) + stock: Optional[int] = Field(None, ge=0) + image_url: Optional[str] = None + is_active: Optional[bool] = None + + +class ProductInDBBase(ProductBase): + id: int + + class Config: + orm_mode = True + + +class Product(ProductInDBBase): + pass \ No newline at end of file diff --git a/app/schemas/token.py b/app/schemas/token.py new file mode 100644 index 0000000..69541e2 --- /dev/null +++ b/app/schemas/token.py @@ -0,0 +1,12 @@ +from typing import Optional + +from pydantic import BaseModel + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenPayload(BaseModel): + sub: Optional[int] = None \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..387fdb8 --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,36 @@ +from typing import Optional + +from pydantic import BaseModel, EmailStr + + +class UserBase(BaseModel): + email: EmailStr + full_name: Optional[str] = None + is_active: Optional[bool] = True + is_admin: bool = False + + +class UserCreate(UserBase): + password: str + + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + full_name: Optional[str] = None + password: Optional[str] = None + is_active: Optional[bool] = None + + +class UserInDBBase(UserBase): + id: int + + class Config: + orm_mode = True + + +class User(UserInDBBase): + pass + + +class UserInDB(UserInDBBase): + password: str \ No newline at end of file diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main.py b/main.py new file mode 100644 index 0000000..2d92364 --- /dev/null +++ b/main.py @@ -0,0 +1,33 @@ +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.routes import api_router +from app.core.config import settings + +app = FastAPI( + title=settings.PROJECT_NAME, + openapi_url=f"{settings.API_V1_STR}/openapi.json" +) + +# Set all CORS enabled origins +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=["*"], + ) + +app.include_router(api_router, prefix=settings.API_V1_STR) + +@app.get("/health", tags=["Health"]) +def health_check(): + """ + Health check endpoint + """ + return {"status": "healthy"} + +if __name__ == "__main__": + uvicorn.run("main:app", host="0.0.0.0", reload=True, port=8000) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..632cf23 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +fastapi>=0.68.0 +uvicorn>=0.15.0 +sqlalchemy>=1.4.23 +passlib>=1.7.4 +bcrypt>=3.2.0 +pydantic>=1.8.2 +python-jose>=3.3.0 +python-multipart>=0.0.5 +alembic>=1.7.1 +email-validator>=1.1.3 +ruff>=0.0.269 \ No newline at end of file