Create comprehensive e-commerce platform with FastAPI and SQLite

This commit is contained in:
Automated Action 2025-05-16 12:45:36 +00:00
parent 7c63d7380c
commit aaa32ca932
62 changed files with 6246 additions and 2 deletions

69
.gitignore vendored Normal file
View File

@ -0,0 +1,69 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
pytest_cache/
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# IDE files
.idea/
.vscode/
*.swp
*.swo
# Database
*.sqlite
*.db
# Logs
logs/
*.log
# OS specific
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Local storage
/app/storage/

39
Dockerfile Normal file
View File

@ -0,0 +1,39 @@
FROM python:3.11-slim
WORKDIR /app
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Copy project files
COPY . .
# Create necessary directories
RUN mkdir -p /app/storage/db /app/storage/logs /app/storage/product_images /app/storage/user_images
# Set permissions
RUN chmod +x /app/scripts/lint.sh
# Initialize the database
RUN python -m app.utils.db_init
# Expose port
EXPOSE 8000
# Command to run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

239
README.md
View File

@ -1,3 +1,238 @@
# FastAPI Application # Comprehensive E-Commerce Platform
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform. A full-featured e-commerce API built with FastAPI and SQLite, designed to provide all the backend functionality needed for a modern online store.
## Features
- **User Management**
- User registration and authentication with JWT
- Role-based access control (Customer, Seller, Admin)
- User profiles with address information
- Password reset functionality
- **Product Management**
- CRUD operations for products
- Multiple product images
- SKU and barcode support
- Inventory tracking
- Digital and physical product support
- **Category & Tag Management**
- Hierarchical category structure
- Product tagging system
- SEO-friendly slugs
- **Shopping Cart**
- Add, update, remove items
- Custom product attributes/options
- Persistent cart for registered users
- **Order Processing**
- Order creation from cart
- Order status tracking
- Order history
- Address management for shipping/billing
- **Payment Integration**
- Multiple payment method support
- Payment status tracking
- Refund handling
- Mock implementations for Stripe and PayPal
- **Inventory Management**
- Stock level tracking
- Low stock alerts
- Stock update APIs
- **Search & Filtering**
- Advanced product search
- Sorting and filtering options
- Relevance-based results
- **Reviews & Ratings**
- User product reviews
- Rating system
- Verified purchase badges
- **Admin Dashboard**
- Sales analytics
- Customer insights
- Product performance metrics
- Order management
- **Security**
- Rate limiting
- Security headers
- Input validation
- Error handling
## Tech Stack
- **Framework**: FastAPI
- **Database**: SQLite with SQLAlchemy ORM
- **Migration**: Alembic
- **Authentication**: JWT with Python-JOSE
- **Validation**: Pydantic
- **Linting**: Ruff
- **Logging**: Python's built-in logging with rotation
## Installation
### Prerequisites
- Python 3.10 or higher
- pip (Python package manager)
### Setup
1. Clone the repository:
```bash
git clone https://github.com/yourusername/comprehensiveecommerceplatform.git
cd comprehensiveecommerceplatform
```
2. Create a virtual environment:
```bash
python -m venv venv
source venv/bin/activate # On Windows, use: venv\Scripts\activate
```
3. Install dependencies:
```bash
pip install -r requirements.txt
```
4. Initialize the database:
```bash
python -m app.utils.db_init
```
## Running the Application
Start the application with:
```bash
uvicorn main:app --reload
```
The API will be available at http://localhost:8000.
- Interactive API documentation: http://localhost:8000/docs
- Alternative API documentation: http://localhost:8000/redoc
- Health check endpoint: http://localhost:8000/health
## API Documentation
The API is fully documented with OpenAPI. You can explore the documentation using the interactive Swagger UI at `/docs` or ReDoc at `/redoc`.
### Authentication
Most endpoints require authentication using JSON Web Tokens (JWT).
To authenticate:
1. Register a user using the `/api/auth/register` endpoint
2. Login using the `/api/auth/login` endpoint
3. Use the returned access token in the Authorization header:
`Authorization: Bearer {your_access_token}`
### Default Admin Account
A default admin account is created when you initialize the database:
- Email: admin@example.com
- Password: admin
## Development
### Project Structure
```
/
├── app/ # Application package
│ ├── core/ # Core functionality
│ │ ├── config.py # Application settings
│ │ ├── database.py # Database connection
│ │ └── security.py # Security utilities
│ ├── dependencies/ # FastAPI dependencies
│ ├── middlewares/ # Custom middlewares
│ ├── models/ # SQLAlchemy models
│ ├── routers/ # API routes
│ ├── schemas/ # Pydantic schemas
│ ├── services/ # Business logic services
│ └── utils/ # Utility functions
├── migrations/ # Alembic migrations
├── scripts/ # Utility scripts
├── main.py # Application entry point
├── requirements.txt # Dependencies
├── alembic.ini # Alembic configuration
└── pyproject.toml # Project configuration
```
### Database Migrations
The project uses Alembic for database migrations. The initial migration is already set up.
To create a new migration after making changes to the models:
```bash
alembic revision --autogenerate -m "Description of changes"
```
To apply migrations:
```bash
alembic upgrade head
```
### Adding New Features
When adding new features:
1. Define models in `app/models/`
2. Create Pydantic schemas in `app/schemas/`
3. Implement business logic in `app/services/`
4. Create API endpoints in `app/routers/`
5. Register new routers in `main.py`
6. Generate and apply database migrations
### Code Quality
Run linting and formatting with:
```bash
./scripts/lint.sh
```
## Deployment
### Production Configuration
For production deployment:
1. Set up a proper database (e.g., PostgreSQL) by updating the connection settings in `app/core/config.py`
2. Configure proper authentication for production in `app/core/config.py`
3. Update CORS settings in `main.py` to restrict allowed origins
4. Set up a reverse proxy (e.g., Nginx) in front of the application
5. Configure HTTPS
### Docker Deployment
A Dockerfile is included for containerized deployment:
```bash
docker build -t ecommerce-api .
docker run -p 8000:8000 ecommerce-api
```
## License
This project is licensed under the MIT License - see the LICENSE file for details.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request

109
alembic.ini Normal file
View File

@ -0,0 +1,109 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
app/__init__.py Normal file
View File

@ -0,0 +1 @@
# E-Commerce application package

1
app/core/__init__.py Normal file
View File

@ -0,0 +1 @@
# Core functionalities for the application

65
app/core/config.py Normal file
View File

@ -0,0 +1,65 @@
import secrets
from pathlib import Path
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# API Settings
API_V1_STR: str = "/api"
PROJECT_NAME: str = "E-Commerce API"
# Security
SECRET_KEY: str = secrets.token_urlsafe(32)
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days
ALGORITHM: str = "HS256"
# CORS
BACKEND_CORS_ORIGINS: list[str] = ["*"]
# Database
DB_DIR: Path = Path("/app") / "storage" / "db"
SQLALCHEMY_DATABASE_URL: str = f"sqlite:///{DB_DIR}/db.sqlite"
# Storage paths
STORAGE_DIR: Path = Path("/app") / "storage"
PRODUCT_IMAGES_DIR: Path = STORAGE_DIR / "product_images"
USER_IMAGES_DIR: Path = STORAGE_DIR / "user_images"
LOGS_DIR: Path = STORAGE_DIR / "logs"
# Payment settings (placeholders for real integration)
STRIPE_API_KEY: str | None = None
PAYPAL_CLIENT_ID: str | None = None
PAYPAL_CLIENT_SECRET: str | None = None
# Email settings (placeholders for real integration)
SMTP_TLS: bool = True
SMTP_PORT: int | None = None
SMTP_HOST: str | None = None
SMTP_USER: str | None = None
SMTP_PASSWORD: str | None = None
EMAILS_FROM_EMAIL: str | None = None
EMAILS_FROM_NAME: str | None = None
# Admin user
FIRST_SUPERUSER_EMAIL: str = "admin@example.com"
FIRST_SUPERUSER_PASSWORD: str = "admin"
# Rate limiting
RATE_LIMIT_PER_MINUTE: int = 60
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()
# Create necessary directories
for directory in [
settings.DB_DIR,
settings.PRODUCT_IMAGES_DIR,
settings.USER_IMAGES_DIR,
settings.LOGS_DIR,
]:
directory.mkdir(parents=True, exist_ok=True)

32
app/core/database.py Normal file
View File

@ -0,0 +1,32 @@
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
# Create database directory
DB_DIR = Path("/app") / "storage" / "db"
DB_DIR.mkdir(parents=True, exist_ok=True)
# Database URL
SQLALCHEMY_DATABASE_URL = f"sqlite:///{DB_DIR}/db.sqlite"
# Create SQLAlchemy engine
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}
)
# Create SessionLocal class
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create Base class
Base = declarative_base()
# Dependency to get DB session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

34
app/core/security.py Normal file
View File

@ -0,0 +1,34 @@
from datetime import datetime, timedelta
from typing import Any
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against a hash."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Generate password hash."""
return pwd_context.hash(password)
def create_access_token(subject: str | Any, expires_delta: timedelta | None = None) -> str:
"""Create a JWT access 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=settings.ALGORITHM
)
return encoded_jwt

View File

@ -0,0 +1 @@
# FastAPI dependency functions

95
app/dependencies/auth.py Normal file
View File

@ -0,0 +1,95 @@
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.core.database import get_db
from app.core.security import verify_password
from app.models.user import User, UserRole
from app.schemas.user import TokenPayload
# OAuth2 password bearer flow for token authentication
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)
) -> User:
"""
Validate the access token and return the current user.
"""
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
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 = db.query(User).filter(User.id == token_data.sub).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
)
return user
def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""
Check if the user is active.
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
)
return current_user
def get_current_seller(
current_user: User = Depends(get_current_active_user),
) -> User:
"""
Check if the user is a seller.
"""
if current_user.role != UserRole.SELLER and current_user.role != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Operation requires seller privileges"
)
return current_user
def get_current_admin(
current_user: User = Depends(get_current_active_user),
) -> User:
"""
Check if the user is an admin.
"""
if current_user.role != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Operation requires admin privileges"
)
return current_user
def authenticate_user(db: Session, email: str, password: str) -> User:
"""
Authenticate a user by email and password.
"""
user = db.query(User).filter(User.email == email).first()
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user

View File

@ -0,0 +1 @@
# Custom middleware components

View File

@ -0,0 +1,155 @@
import logging
import time
from collections.abc import Callable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
logger = logging.getLogger(__name__)
class RateLimiter:
"""
Simple in-memory rate limiter implementation.
For production use, consider using Redis or another distributed store.
"""
def __init__(self, rate_limit_per_minute: int = 60):
self.rate_limit_per_minute = rate_limit_per_minute
self.requests: dict[str, dict[float, int]] = {}
self.window_size = 60 # 1 minute in seconds
def is_rate_limited(self, client_id: str) -> tuple[bool, dict]:
"""
Check if a client is rate limited.
Args:
client_id: Identifier for the client (usually IP address)
Returns:
Tuple of (is_limited, rate_limit_info)
"""
current_time = time.time()
# Initialize client record if it doesn't exist
if client_id not in self.requests:
self.requests[client_id] = {}
# Clean up old records
self._cleanup(client_id, current_time)
# Count recent requests
recent_requests = sum(self.requests[client_id].values())
# Check if rate limit is exceeded
is_limited = recent_requests >= self.rate_limit_per_minute
# Update request count if not limited
if not is_limited:
self.requests[client_id][current_time] = self.requests[client_id].get(current_time, 0) + 1
# Calculate rate limit info
remaining = max(0, self.rate_limit_per_minute - recent_requests)
reset_at = current_time + self.window_size
return is_limited, {
"limit": self.rate_limit_per_minute,
"remaining": remaining,
"reset": int(reset_at),
}
def _cleanup(self, client_id: str, current_time: float) -> None:
"""
Clean up old records for a client.
Args:
client_id: Identifier for the client
current_time: Current timestamp
"""
cutoff_time = current_time - self.window_size
timestamps_to_remove = [ts for ts in self.requests[client_id].keys() if ts < cutoff_time]
for ts in timestamps_to_remove:
del self.requests[client_id][ts]
class RateLimitMiddleware(BaseHTTPMiddleware):
"""
Middleware for rate limiting API requests.
"""
def __init__(
self,
app: ASGIApp,
rate_limit_per_minute: int = 60,
whitelist_paths: list | None = None,
client_id_func: Callable[[Request], str] | None = None
):
super().__init__(app)
self.rate_limiter = RateLimiter(rate_limit_per_minute)
self.whitelist_paths = whitelist_paths or ["/health", "/docs", "/redoc", "/openapi.json"]
self.client_id_func = client_id_func or self._default_client_id
async def dispatch(self, request: Request, call_next) -> Response:
"""
Process the request through rate limiting.
Args:
request: The incoming request
call_next: The next handler in the middleware chain
Returns:
The response
"""
# Skip rate limiting for whitelisted paths
path = request.url.path
if any(path.startswith(wl_path) for wl_path in self.whitelist_paths):
return await call_next(request)
# Get client identifier
client_id = self.client_id_func(request)
# Check if rate limited
is_limited, rate_limit_info = self.rate_limiter.is_rate_limited(client_id)
# If rate limited, return 429 Too Many Requests
if is_limited:
logger.warning(f"Rate limit exceeded for client {client_id}")
response = Response(
content={"detail": "Rate limit exceeded"},
status_code=429,
media_type="application/json"
)
else:
# Process the request normally
response = await call_next(request)
# Add rate limit headers to response
response.headers["X-RateLimit-Limit"] = str(rate_limit_info["limit"])
response.headers["X-RateLimit-Remaining"] = str(rate_limit_info["remaining"])
response.headers["X-RateLimit-Reset"] = str(rate_limit_info["reset"])
return response
def _default_client_id(self, request: Request) -> str:
"""
Default function to extract client identifier from request.
Uses the client's IP address.
Args:
request: The incoming request
Returns:
Client identifier string
"""
# Try to get real IP from forwarded header (for proxies)
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
# Get the first IP in the list (client IP)
return forwarded_for.split(",")[0].strip()
# Fall back to the direct client address
return request.client.host if request.client else "unknown"

View File

@ -0,0 +1,47 @@
import logging
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp
logger = logging.getLogger(__name__)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""
Middleware to add security headers to responses.
"""
def __init__(
self,
app: ASGIApp,
content_security_policy: str = None,
):
super().__init__(app)
self.content_security_policy = content_security_policy or "default-src 'self'"
async def dispatch(self, request: Request, call_next) -> Response:
"""
Process the request and add security headers to the response.
Args:
request: The incoming request
call_next: The next handler in the middleware chain
Returns:
The response with added security headers
"""
# Process the request
response = await call_next(request)
# Add security headers
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = self.content_security_policy
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
response.headers["Pragma"] = "no-cache"
return response

9
app/models/__init__.py Normal file
View File

@ -0,0 +1,9 @@
# Import all models here for easier access from other parts of the application
from app.models.cart import CartItem
from app.models.category import Category
from app.models.order import Order, OrderItem
from app.models.payment import Payment
from app.models.product import Product, ProductImage
from app.models.review import Review
from app.models.tag import ProductTag, Tag
from app.models.user import User

32
app/models/cart.py Normal file
View File

@ -0,0 +1,32 @@
from uuid import uuid4
from sqlalchemy import Column, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class CartItem(Base):
__tablename__ = "cart_items"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
product_id = Column(String(36), ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
quantity = Column(Integer, default=1, nullable=False)
price_at_addition = Column(Float, nullable=False) # Price when added to cart
custom_properties = Column(Text, nullable=True) # JSON string for custom product properties
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
user = relationship("User", back_populates="cart_items")
product = relationship("Product", back_populates="cart_items")
def __repr__(self):
return f"<CartItem {self.id} for Product {self.product_id}>"
@property
def subtotal(self):
"""Calculate the subtotal for this cart item"""
return self.price_at_addition * self.quantity

29
app/models/category.py Normal file
View File

@ -0,0 +1,29 @@
from uuid import uuid4
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class Category(Base):
__tablename__ = "categories"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
name = Column(String(100), nullable=False, index=True)
slug = Column(String(120), nullable=False, unique=True)
description = Column(Text, nullable=True)
image = Column(String(255), nullable=True)
parent_id = Column(String(36), ForeignKey("categories.id"), nullable=True)
is_active = Column(Boolean, default=True)
display_order = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
parent = relationship("Category", remote_side=[id], backref="subcategories")
products = relationship("Product", back_populates="category")
def __repr__(self):
return f"<Category {self.name}>"

75
app/models/order.py Normal file
View File

@ -0,0 +1,75 @@
import enum
from uuid import uuid4
from sqlalchemy import JSON, Column, DateTime, Enum, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class OrderStatus(enum.Enum):
PENDING = "pending"
PROCESSING = "processing"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
REFUNDED = "refunded"
class ShippingMethod(enum.Enum):
STANDARD = "standard"
EXPRESS = "express"
OVERNIGHT = "overnight"
PICKUP = "pickup"
DIGITAL = "digital"
class Order(Base):
__tablename__ = "orders"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
order_number = Column(String(50), nullable=False, unique=True, index=True)
status = Column(Enum(OrderStatus), default=OrderStatus.PENDING)
total_amount = Column(Float, nullable=False)
subtotal = Column(Float, nullable=False)
tax_amount = Column(Float, nullable=False)
shipping_amount = Column(Float, nullable=False)
discount_amount = Column(Float, default=0.0)
shipping_method = Column(Enum(ShippingMethod), nullable=True)
tracking_number = Column(String(100), nullable=True)
notes = Column(Text, nullable=True)
shipping_address = Column(JSON, nullable=True) # JSON containing shipping address
billing_address = Column(JSON, nullable=True) # JSON containing billing address
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
user = relationship("User", back_populates="orders")
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
payments = relationship("Payment", back_populates="order", cascade="all, delete-orphan")
def __repr__(self):
return f"<Order {self.order_number}>"
class OrderItem(Base):
__tablename__ = "order_items"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
order_id = Column(String(36), ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
product_id = Column(String(36), ForeignKey("products.id"), nullable=False)
quantity = Column(Integer, default=1, nullable=False)
unit_price = Column(Float, nullable=False) # Price at the time of purchase
subtotal = Column(Float, nullable=False) # unit_price * quantity
discount = Column(Float, default=0.0)
tax_amount = Column(Float, default=0.0)
product_name = Column(String(255), nullable=False) # Store name in case product is deleted
product_sku = Column(String(100), nullable=True)
product_options = Column(JSON, nullable=True) # JSON containing selected options
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
order = relationship("Order", back_populates="items")
product = relationship("Product", back_populates="order_items")
def __repr__(self):
return f"<OrderItem {self.id} for Order {self.order_id}>"

45
app/models/payment.py Normal file
View File

@ -0,0 +1,45 @@
import enum
from uuid import uuid4
from sqlalchemy import JSON, Column, DateTime, Enum, Float, ForeignKey, String
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class PaymentStatus(enum.Enum):
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
REFUNDED = "refunded"
class PaymentMethod(enum.Enum):
CREDIT_CARD = "credit_card"
PAYPAL = "paypal"
BANK_TRANSFER = "bank_transfer"
CASH_ON_DELIVERY = "cash_on_delivery"
STRIPE = "stripe"
APPLE_PAY = "apple_pay"
GOOGLE_PAY = "google_pay"
class Payment(Base):
__tablename__ = "payments"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
order_id = Column(String(36), ForeignKey("orders.id"), nullable=False)
amount = Column(Float, nullable=False)
payment_method = Column(Enum(PaymentMethod), nullable=False)
status = Column(Enum(PaymentStatus), default=PaymentStatus.PENDING)
transaction_id = Column(String(255), nullable=True, unique=True)
payment_details = Column(JSON, nullable=True) # JSON with payment provider details
error_message = Column(String(512), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
order = relationship("Order", back_populates="payments")
def __repr__(self):
return f"<Payment {self.id} for Order {self.order_id}>"

87
app/models/product.py Normal file
View File

@ -0,0 +1,87 @@
import enum
from uuid import uuid4
from sqlalchemy import Boolean, Column, DateTime, Enum, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class ProductStatus(enum.Enum):
DRAFT = "draft"
PUBLISHED = "published"
OUT_OF_STOCK = "out_of_stock"
DISCONTINUED = "discontinued"
class Product(Base):
__tablename__ = "products"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
name = Column(String(255), nullable=False, index=True)
description = Column(Text, nullable=True)
price = Column(Float, nullable=False)
sku = Column(String(100), unique=True, nullable=True)
barcode = Column(String(100), unique=True, nullable=True)
stock_quantity = Column(Integer, default=0)
weight = Column(Float, nullable=True) # In kg
dimensions = Column(String(100), nullable=True) # Format: LxWxH in cm
status = Column(Enum(ProductStatus), default=ProductStatus.DRAFT)
is_featured = Column(Boolean, default=False)
is_digital = Column(Boolean, default=False)
digital_download_link = Column(String(512), nullable=True)
slug = Column(String(255), nullable=False, unique=True)
tax_rate = Column(Float, default=0.0) # As a percentage
discount_price = Column(Float, nullable=True)
discount_start_date = Column(DateTime(timezone=True), nullable=True)
discount_end_date = Column(DateTime(timezone=True), nullable=True)
category_id = Column(String(36), ForeignKey("categories.id"), nullable=True)
seller_id = Column(String(36), ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
category = relationship("Category", back_populates="products")
seller = relationship("User", back_populates="products")
images = relationship("ProductImage", back_populates="product", cascade="all, delete-orphan")
reviews = relationship("Review", back_populates="product", cascade="all, delete-orphan")
order_items = relationship("OrderItem", back_populates="product")
cart_items = relationship("CartItem", back_populates="product")
# Tags relationship
tags = relationship("Tag", secondary="product_tags")
def __repr__(self):
return f"<Product {self.name}>"
@property
def average_rating(self):
if not self.reviews:
return None
return sum(review.rating for review in self.reviews) / len(self.reviews)
@property
def current_price(self):
"""Returns the current effective price (discount or regular)"""
now = func.now()
if (self.discount_price and self.discount_start_date and self.discount_end_date and
self.discount_start_date <= now and now <= self.discount_end_date):
return self.discount_price
return self.price
class ProductImage(Base):
__tablename__ = "product_images"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
product_id = Column(String(36), ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
image_url = Column(String(512), nullable=False)
alt_text = Column(String(255), nullable=True)
is_primary = Column(Boolean, default=False)
display_order = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationship
product = relationship("Product", back_populates="images")
def __repr__(self):
return f"<ProductImage {self.id} for Product {self.product_id}>"

29
app/models/review.py Normal file
View File

@ -0,0 +1,29 @@
from uuid import uuid4
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class Review(Base):
__tablename__ = "reviews"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
product_id = Column(String(36), ForeignKey("products.id", ondelete="CASCADE"), nullable=False)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
rating = Column(Integer, nullable=False) # 1-5 rating
title = Column(String(255), nullable=True)
comment = Column(Text, nullable=True)
is_verified_purchase = Column(Boolean, default=False)
is_approved = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
product = relationship("Product", back_populates="reviews")
user = relationship("User", back_populates="reviews")
def __repr__(self):
return f"<Review {self.id} by User {self.user_id} for Product {self.product_id}>"

27
app/models/tag.py Normal file
View File

@ -0,0 +1,27 @@
from uuid import uuid4
from sqlalchemy import Column, DateTime, ForeignKey, String
from sqlalchemy.sql import func
from app.core.database import Base
# Association table for product-tag relationship
class ProductTag(Base):
__tablename__ = "product_tags"
product_id = Column(String(36), ForeignKey("products.id", ondelete="CASCADE"), primary_key=True)
tag_id = Column(String(36), ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class Tag(Base):
__tablename__ = "tags"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
name = Column(String(50), nullable=False, unique=True, index=True)
slug = Column(String(60), nullable=False, unique=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
def __repr__(self):
return f"<Tag {self.name}>"

55
app/models/user.py Normal file
View File

@ -0,0 +1,55 @@
import enum
from uuid import uuid4
from sqlalchemy import Boolean, Column, DateTime, Enum, String, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
class UserRole(enum.Enum):
CUSTOMER = "customer"
SELLER = "seller"
ADMIN = "admin"
class User(Base):
__tablename__ = "users"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
first_name = Column(String(100), nullable=True)
last_name = Column(String(100), nullable=True)
is_active = Column(Boolean, default=True)
role = Column(Enum(UserRole), default=UserRole.CUSTOMER)
phone_number = Column(String(20), nullable=True)
profile_image = Column(String(255), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
orders = relationship("Order", back_populates="user")
reviews = relationship("Review", back_populates="user")
cart_items = relationship("CartItem", back_populates="user")
# For sellers: products they are selling
products = relationship("Product", back_populates="seller")
# Address information (simplified - in a real app you'd likely have a separate table)
address_line1 = Column(String(255), nullable=True)
address_line2 = Column(String(255), nullable=True)
city = Column(String(100), nullable=True)
state = Column(String(100), nullable=True)
postal_code = Column(String(20), nullable=True)
country = Column(String(100), nullable=True)
# Additional fields
email_verified = Column(Boolean, default=False)
verification_token = Column(String(255), nullable=True)
reset_password_token = Column(String(255), nullable=True)
reset_token_expires_at = Column(DateTime(timezone=True), nullable=True)
bio = Column(Text, nullable=True)
def __repr__(self):
return f"<User {self.email}>"

1
app/routers/__init__.py Normal file
View File

@ -0,0 +1 @@
# API router modules

98
app/routers/admin.py Normal file
View File

@ -0,0 +1,98 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.dependencies.auth import get_current_admin
from app.models.user import User
from app.schemas.admin import (
DashboardSummary,
OrdersPerStatus,
SalesOverTime,
SalesSummary,
TimePeriod,
TopCategorySales,
TopCustomerSales,
TopProductSales,
)
from app.services.admin import AdminDashboardService
router = APIRouter()
@router.get("/dashboard", response_model=DashboardSummary)
async def get_dashboard_summary(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Get a summary of key metrics for the admin dashboard.
"""
return AdminDashboardService.get_dashboard_summary(db)
@router.get("/sales/summary", response_model=SalesSummary)
async def get_sales_summary(
period: TimePeriod = TimePeriod.LAST_30_DAYS,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Get sales summary for a specific time period.
"""
return AdminDashboardService.get_sales_summary(db, period)
@router.get("/sales/over-time", response_model=SalesOverTime)
async def get_sales_over_time(
period: TimePeriod = TimePeriod.LAST_30_DAYS,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Get sales data over time for a specific period.
"""
return AdminDashboardService.get_sales_over_time(db, period)
@router.get("/sales/top-categories", response_model=TopCategorySales)
async def get_top_categories(
period: TimePeriod = TimePeriod.LAST_30_DAYS,
limit: int = Query(5, ge=1, le=50),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Get top selling categories for a specific period.
"""
return AdminDashboardService.get_top_categories(db, period, limit)
@router.get("/sales/top-products", response_model=TopProductSales)
async def get_top_products(
period: TimePeriod = TimePeriod.LAST_30_DAYS,
limit: int = Query(5, ge=1, le=50),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Get top selling products for a specific period.
"""
return AdminDashboardService.get_top_products(db, period, limit)
@router.get("/sales/top-customers", response_model=TopCustomerSales)
async def get_top_customers(
period: TimePeriod = TimePeriod.LAST_30_DAYS,
limit: int = Query(5, ge=1, le=50),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Get top customers for a specific period.
"""
return AdminDashboardService.get_top_customers(db, period, limit)
@router.get("/orders/by-status", response_model=list[OrdersPerStatus])
async def get_orders_by_status(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Get order counts by status.
"""
return AdminDashboardService.get_orders_by_status(db)

118
app/routers/auth.py Normal file
View File

@ -0,0 +1,118 @@
import logging
from datetime import timedelta
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.database import get_db
from app.core.security import create_access_token, get_password_hash
from app.dependencies.auth import authenticate_user, get_current_active_user
from app.models.user import User, UserRole
from app.schemas.user import Token, UserCreate
from app.schemas.user import User as UserSchema
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""
OAuth2 compatible token login, get an access token for future requests.
"""
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
subject=user.id, expires_delta=access_token_expires
)
logger.info(f"User {user.email} logged in successfully")
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/register", response_model=UserSchema)
async def register(
user_in: UserCreate,
db: Session = Depends(get_db)
):
"""
Register a new user.
"""
# Check if user with this email already exists
user = db.query(User).filter(User.email == user_in.email).first()
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this email already exists",
)
# Create new user
db_user = User(
email=user_in.email,
hashed_password=get_password_hash(user_in.password),
first_name=user_in.first_name,
last_name=user_in.last_name,
phone_number=user_in.phone_number,
role=UserRole.CUSTOMER,
is_active=True,
)
db.add(db_user)
db.commit()
db.refresh(db_user)
logger.info(f"New user registered with email: {db_user.email}")
return db_user
@router.post("/register/seller", response_model=UserSchema)
async def register_seller(
user_in: UserCreate,
db: Session = Depends(get_db)
):
"""
Register a new seller account.
"""
# Check if user with this email already exists
user = db.query(User).filter(User.email == user_in.email).first()
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A user with this email already exists",
)
# Create new seller user
db_user = User(
email=user_in.email,
hashed_password=get_password_hash(user_in.password),
first_name=user_in.first_name,
last_name=user_in.last_name,
phone_number=user_in.phone_number,
role=UserRole.SELLER,
is_active=True,
)
db.add(db_user)
db.commit()
db.refresh(db_user)
logger.info(f"New seller registered with email: {db_user.email}")
return db_user
@router.get("/me", response_model=UserSchema)
async def read_users_me(
current_user: User = Depends(get_current_active_user)
):
"""
Get current user information.
"""
return current_user

293
app/routers/cart.py Normal file
View File

@ -0,0 +1,293 @@
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.dependencies.auth import get_current_active_user
from app.models.cart import CartItem
from app.models.product import Product, ProductStatus
from app.models.user import User
from app.schemas.cart import (
CartItem as CartItemSchema,
)
from app.schemas.cart import (
CartItemCreate,
CartItemUpdate,
CartSummary,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/", response_model=CartSummary)
async def get_cart(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Get the current user's shopping cart.
"""
cart_items = db.query(CartItem).filter(CartItem.user_id == current_user.id).all()
# Enhance cart items with product information
items = []
total_items = 0
subtotal = 0
total_weight = 0
for item in cart_items:
# Skip items with deleted products
if not item.product:
continue
# Skip items with unavailable products
if item.product.status != ProductStatus.PUBLISHED and item.product.status != ProductStatus.OUT_OF_STOCK:
continue
# Get primary image if available
product_image = None
primary_image = next((img for img in item.product.images if img.is_primary), None)
if primary_image:
product_image = primary_image.image_url
elif item.product.images:
product_image = item.product.images[0].image_url
# Calculate current price and subtotal
current_price = item.product.current_price
item_subtotal = current_price * item.quantity
# Parse custom properties
custom_properties = None
if item.custom_properties:
try:
custom_properties = json.loads(item.custom_properties)
except:
custom_properties = None
# Create enhanced cart item
cart_item = {
"id": item.id,
"user_id": item.user_id,
"product_id": item.product_id,
"quantity": item.quantity,
"price_at_addition": item.price_at_addition,
"custom_properties": custom_properties,
"created_at": item.created_at,
"updated_at": item.updated_at,
"product_name": item.product.name,
"product_image": product_image,
"current_price": current_price,
"subtotal": item_subtotal
}
items.append(cart_item)
total_items += item.quantity
subtotal += item_subtotal
# Add weight if available
if item.product.weight:
total_weight += item.product.weight * item.quantity
return {
"items": items,
"total_items": total_items,
"subtotal": subtotal,
"total_weight": total_weight if total_weight > 0 else None
}
@router.post("/items", response_model=CartItemSchema)
async def add_cart_item(
item_in: CartItemCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Add an item to the shopping cart.
"""
# Check if product exists and is available
product = db.query(Product).filter(Product.id == item_in.product_id).first()
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
if product.status != ProductStatus.PUBLISHED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Product is not available for purchase"
)
if product.stock_quantity < item_in.quantity:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Not enough stock available. Only {product.stock_quantity} items left."
)
# Check if the item is already in the cart
existing_item = db.query(CartItem).filter(
CartItem.user_id == current_user.id,
CartItem.product_id == item_in.product_id
).first()
if existing_item:
# Update quantity if item already exists
new_quantity = existing_item.quantity + item_in.quantity
# Check stock for the new quantity
if product.stock_quantity < new_quantity:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Not enough stock available. Only {product.stock_quantity} items left."
)
existing_item.quantity = new_quantity
# Update custom properties if provided
if item_in.custom_properties:
existing_item.custom_properties = json.dumps(item_in.custom_properties)
db.commit()
db.refresh(existing_item)
cart_item = existing_item
else:
# Serialize custom properties if provided
custom_properties_json = None
if item_in.custom_properties:
custom_properties_json = json.dumps(item_in.custom_properties)
# Create new cart item
cart_item = CartItem(
user_id=current_user.id,
product_id=item_in.product_id,
quantity=item_in.quantity,
price_at_addition=product.current_price,
custom_properties=custom_properties_json
)
db.add(cart_item)
db.commit()
db.refresh(cart_item)
# Enhance cart item with product information for response
product_image = None
primary_image = next((img for img in product.images if img.is_primary), None)
if primary_image:
product_image = primary_image.image_url
elif product.images:
product_image = product.images[0].image_url
cart_item.product_name = product.name
cart_item.product_image = product_image
cart_item.current_price = product.current_price
cart_item.subtotal = product.current_price * cart_item.quantity
logger.info(f"Item added to cart: Product {product.name} (ID: {product.id}) for user {current_user.email}")
return cart_item
@router.put("/items/{item_id}", response_model=CartItemSchema)
async def update_cart_item(
item_id: str,
item_in: CartItemUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Update a cart item.
"""
# Find the cart item
cart_item = db.query(CartItem).filter(
CartItem.id == item_id,
CartItem.user_id == current_user.id
).first()
if not cart_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Cart item not found"
)
# Check if product exists
product = db.query(Product).filter(Product.id == cart_item.product_id).first()
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
# Update quantity if provided
if item_in.quantity is not None:
# Check stock
if product.stock_quantity < item_in.quantity:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Not enough stock available. Only {product.stock_quantity} items left."
)
cart_item.quantity = item_in.quantity
# Update custom properties if provided
if item_in.custom_properties is not None:
cart_item.custom_properties = json.dumps(item_in.custom_properties)
db.commit()
db.refresh(cart_item)
# Enhance cart item with product information for response
product_image = None
primary_image = next((img for img in product.images if img.is_primary), None)
if primary_image:
product_image = primary_image.image_url
elif product.images:
product_image = product.images[0].image_url
cart_item.product_name = product.name
cart_item.product_image = product_image
cart_item.current_price = product.current_price
cart_item.subtotal = product.current_price * cart_item.quantity
logger.info(f"Cart item updated: ID {item_id} for user {current_user.email}")
return cart_item
@router.delete("/items/{item_id}", response_model=dict)
async def remove_cart_item(
item_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Remove an item from the shopping cart.
"""
cart_item = db.query(CartItem).filter(
CartItem.id == item_id,
CartItem.user_id == current_user.id
).first()
if not cart_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Cart item not found"
)
# Delete the cart item
db.delete(cart_item)
db.commit()
logger.info(f"Cart item removed: ID {item_id} for user {current_user.email}")
return {"message": "Item removed from cart successfully"}
@router.delete("/", response_model=dict)
async def clear_cart(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Clear the current user's shopping cart.
"""
db.query(CartItem).filter(CartItem.user_id == current_user.id).delete()
db.commit()
logger.info(f"Cart cleared for user {current_user.email}")
return {"message": "Cart cleared successfully"}

313
app/routers/categories.py Normal file
View File

@ -0,0 +1,313 @@
import logging
import os
import re
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.dependencies.auth import get_current_admin
from app.models.category import Category
from app.models.user import User
from app.schemas.category import (
Category as CategorySchema,
)
from app.schemas.category import (
CategoryCreate,
CategoryUpdate,
CategoryWithChildren,
)
router = APIRouter()
logger = logging.getLogger(__name__)
def slugify(text):
"""Convert a string to a URL-friendly slug."""
# Remove non-alphanumeric characters
text = re.sub(r'[^\w\s-]', '', text.lower())
# Replace spaces with hyphens
text = re.sub(r'[\s]+', '-', text)
# Remove consecutive hyphens
text = re.sub(r'[-]+', '-', text)
# Add timestamp to ensure uniqueness
timestamp = int(datetime.now().timestamp())
return f"{text}-{timestamp}"
def count_products_in_category(category, include_subcategories=True):
"""Count products in a category and optionally in its subcategories."""
product_count = len(category.products)
if include_subcategories:
for subcategory in category.subcategories:
product_count += count_products_in_category(subcategory)
return product_count
def build_category_tree(categories, parent_id=None):
"""Recursively build a category tree structure."""
tree = []
for category in categories:
if category.parent_id == parent_id:
# Convert to CategoryWithChildren schema
category_dict = {
"id": category.id,
"name": category.name,
"slug": category.slug,
"description": category.description,
"image": category.image,
"parent_id": category.parent_id,
"is_active": category.is_active,
"display_order": category.display_order,
"created_at": category.created_at,
"updated_at": category.updated_at,
"subcategories": build_category_tree(categories, category.id),
"product_count": count_products_in_category(category)
}
tree.append(category_dict)
# Sort by display_order
tree.sort(key=lambda x: x["display_order"])
return tree
@router.get("/", response_model=list[CategorySchema])
async def get_categories(
skip: int = 0,
limit: int = 100,
active_only: bool = True,
db: Session = Depends(get_db)
):
"""
Get all categories.
"""
query = db.query(Category)
if active_only:
query = query.filter(Category.is_active == True)
categories = query.order_by(Category.display_order).offset(skip).limit(limit).all()
return categories
@router.get("/tree", response_model=list[CategoryWithChildren])
async def get_category_tree(
active_only: bool = True,
db: Session = Depends(get_db)
):
"""
Get categories in a hierarchical tree structure.
"""
query = db.query(Category)
if active_only:
query = query.filter(Category.is_active == True)
categories = query.all()
tree = build_category_tree(categories)
return tree
@router.get("/{category_id}", response_model=CategorySchema)
async def get_category(
category_id: str,
db: Session = Depends(get_db)
):
"""
Get a specific category by ID.
"""
category = db.query(Category).filter(Category.id == category_id).first()
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found"
)
return category
@router.post("/", response_model=CategorySchema)
async def create_category(
category_in: CategoryCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Create a new category (admin only).
"""
# Check if slug already exists
existing_category = db.query(Category).filter(Category.slug == category_in.slug).first()
if existing_category:
# If slug exists, create a unique one
category_in.slug = slugify(category_in.name)
# Check if parent category exists (if a parent is specified)
if category_in.parent_id:
parent_category = db.query(Category).filter(Category.id == category_in.parent_id).first()
if not parent_category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Parent category not found"
)
# Create new category
db_category = Category(
name=category_in.name,
slug=category_in.slug,
description=category_in.description,
image=category_in.image,
parent_id=category_in.parent_id,
is_active=category_in.is_active,
display_order=category_in.display_order
)
db.add(db_category)
db.commit()
db.refresh(db_category)
logger.info(f"Category created: {db_category.name} (ID: {db_category.id})")
return db_category
@router.put("/{category_id}", response_model=CategorySchema)
async def update_category(
category_id: str,
category_in: CategoryUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Update a category (admin only).
"""
category = db.query(Category).filter(Category.id == category_id).first()
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found"
)
# Check if slug already exists (if updating slug)
if category_in.slug and category_in.slug != category.slug:
existing_category = db.query(Category).filter(Category.slug == category_in.slug).first()
if existing_category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Slug already exists"
)
# Check if parent category exists (if updating parent)
if category_in.parent_id and category_in.parent_id != category.parent_id:
# Prevent setting a category as its own parent
if category_in.parent_id == category_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Category cannot be its own parent"
)
parent_category = db.query(Category).filter(Category.id == category_in.parent_id).first()
if not parent_category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Parent category not found"
)
# Prevent circular references
current_parent = parent_category
while current_parent:
if current_parent.id == category_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Circular reference in category hierarchy"
)
current_parent = db.query(Category).filter(Category.id == current_parent.parent_id).first()
# Update category attributes
for key, value in category_in.dict(exclude_unset=True).items():
setattr(category, key, value)
db.commit()
db.refresh(category)
logger.info(f"Category updated: {category.name} (ID: {category.id})")
return category
@router.delete("/{category_id}", response_model=dict)
async def delete_category(
category_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Delete a category (admin only).
"""
category = db.query(Category).filter(Category.id == category_id).first()
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found"
)
# Check if category has products
if category.products:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete category with products. Remove products first or reassign them."
)
# Check if category has subcategories
if category.subcategories:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete category with subcategories. Delete subcategories first."
)
# Delete the category
db.delete(category)
db.commit()
logger.info(f"Category deleted: {category.name} (ID: {category.id})")
return {"message": "Category successfully deleted"}
@router.post("/{category_id}/image", response_model=CategorySchema)
async def upload_category_image(
category_id: str,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Upload an image for a category (admin only).
"""
category = db.query(Category).filter(Category.id == category_id).first()
if not category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found"
)
# Validate file
content_type = file.content_type
if not content_type.startswith("image/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an image"
)
# Create category images directory
category_images_dir = settings.STORAGE_DIR / "category_images"
category_images_dir.mkdir(parents=True, exist_ok=True)
# Generate unique filename
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{uuid.uuid4()}{file_extension}"
file_path = category_images_dir / unique_filename
# Save the file
with open(file_path, "wb") as buffer:
buffer.write(await file.read())
# Update the category's image in the database
relative_path = f"/storage/category_images/{unique_filename}"
category.image = relative_path
db.commit()
db.refresh(category)
logger.info(f"Image uploaded for category ID {category_id}: {unique_filename}")
return category

27
app/routers/health.py Normal file
View File

@ -0,0 +1,27 @@
import time
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
router = APIRouter()
@router.get("/health")
async def health_check(db: Session = Depends(get_db)):
"""
Health check endpoint that verifies the API is running
and can connect to the database.
"""
try:
# Check database connection by executing a simple query
db.execute("SELECT 1")
db_status = "healthy"
except Exception as e:
db_status = f"unhealthy: {str(e)}"
return {
"status": "online",
"timestamp": time.time(),
"database": db_status,
}

147
app/routers/inventory.py Normal file
View File

@ -0,0 +1,147 @@
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.dependencies.auth import get_current_admin, get_current_seller
from app.models.product import Product
from app.models.user import User
from app.schemas.product import Product as ProductSchema
from app.services.inventory import InventoryService
router = APIRouter()
@router.put("/products/{product_id}/stock", response_model=ProductSchema)
async def update_product_stock(
product_id: str,
quantity: int = Body(..., ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_seller)
):
"""
Update the stock quantity of a product.
Seller can only update their own products.
Admin can update any product.
"""
# Check if product exists and belongs to the current user
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
# Check ownership
if current_user.id != product.seller_id and not any(role.name == "admin" for role in current_user.roles):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to update this product"
)
# Get current stock
current_stock = product.stock_quantity
# Calculate change
if quantity > current_stock:
# Add stock
quantity_change = quantity - current_stock
product = InventoryService.update_stock(db, product_id, quantity_change, "add")
elif quantity < current_stock:
# Remove stock
quantity_change = current_stock - quantity
product = InventoryService.update_stock(db, product_id, quantity_change, "subtract")
else:
# No change
return product
return product
@router.put("/products/{product_id}/stock/add", response_model=ProductSchema)
async def add_stock(
product_id: str,
quantity: int = Body(..., gt=0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_seller)
):
"""
Add stock to a product.
"""
# Check if product exists and belongs to the current user
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
# Check ownership
if current_user.id != product.seller_id and not any(role.name == "admin" for role in current_user.roles):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to update this product"
)
product = InventoryService.update_stock(db, product_id, quantity, "add")
return product
@router.put("/products/{product_id}/stock/subtract", response_model=ProductSchema)
async def subtract_stock(
product_id: str,
quantity: int = Body(..., gt=0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_seller)
):
"""
Subtract stock from a product.
"""
# Check if product exists and belongs to the current user
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
# Check ownership
if current_user.id != product.seller_id and not any(role.name == "admin" for role in current_user.roles):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to update this product"
)
product = InventoryService.update_stock(db, product_id, quantity, "subtract")
return product
@router.get("/low-stock", response_model=list[ProductSchema])
async def get_low_stock_products(
threshold: int = 5,
category_id: str | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_seller)
):
"""
Get products with low stock.
Seller can only see their own products.
Admin can see all products.
"""
# For sellers, always filter by seller_id
seller_id = None if any(role.name == "admin" for role in current_user.roles) else current_user.id
products = InventoryService.get_low_stock_products(
db, threshold, category_id, seller_id
)
return products
@router.put("/bulk-update", response_model=list[ProductSchema])
async def bulk_update_stock(
updates: list[dict[str, Any]] = Body(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Update stock for multiple products at once (admin only).
"""
products = InventoryService.bulk_update_stock(db, updates)
return products

323
app/routers/orders.py Normal file
View File

@ -0,0 +1,323 @@
import json
import logging
import random
import string
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import desc
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.dependencies.auth import get_current_active_user, get_current_admin
from app.models.cart import CartItem
from app.models.order import Order, OrderItem, OrderStatus
from app.models.product import Product, ProductStatus
from app.models.user import User, UserRole
from app.schemas.order import (
Order as OrderSchema,
)
from app.schemas.order import (
OrderCreate,
OrderSummary,
OrderUpdate,
)
router = APIRouter()
logger = logging.getLogger(__name__)
def generate_order_number():
"""Generate a unique order number."""
timestamp = datetime.now().strftime("%Y%m%d")
random_chars = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
return f"ORD-{timestamp}-{random_chars}"
@router.get("/", response_model=list[OrderSummary])
async def get_orders(
skip: int = 0,
limit: int = 100,
status: OrderStatus | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Get all orders for the current user.
Admins can filter by user ID.
"""
query = db.query(Order)
# Filter by user ID (regular users can only see their own orders)
if current_user.role != UserRole.ADMIN:
query = query.filter(Order.user_id == current_user.id)
# Filter by status if provided
if status:
query = query.filter(Order.status == status)
# Apply pagination and order by newest first
orders = query.order_by(desc(Order.created_at)).offset(skip).limit(limit).all()
# Add item count to each order
for order in orders:
order.item_count = sum(item.quantity for item in order.items)
return orders
@router.get("/{order_id}", response_model=OrderSchema)
async def get_order(
order_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Get a specific order by ID.
Regular users can only get their own orders.
Admins can get any order.
"""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Order not found"
)
# Check permissions
if current_user.role != UserRole.ADMIN and order.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to access this order"
)
return order
@router.post("/", response_model=OrderSchema)
async def create_order(
order_in: OrderCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Create a new order from the shopping cart.
"""
# Validate cart items
if not order_in.cart_items:
# If no cart items are specified, use all items from the user's cart
cart_items = db.query(CartItem).filter(CartItem.user_id == current_user.id).all()
if not cart_items:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Shopping cart is empty"
)
cart_item_ids = [item.id for item in cart_items]
else:
# If specific cart items are specified, validate them
cart_items = db.query(CartItem).filter(
CartItem.id.in_(order_in.cart_items),
CartItem.user_id == current_user.id
).all()
if len(cart_items) != len(order_in.cart_items):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="One or more cart items not found"
)
cart_item_ids = order_in.cart_items
# Calculate totals
subtotal = 0
tax_amount = 0
shipping_amount = 10.0 # Default shipping cost (should be calculated based on shipping method, weight, etc.)
discount_amount = 0
# Use default user addresses if requested
if order_in.use_default_addresses:
shipping_address = {
"first_name": current_user.first_name or "",
"last_name": current_user.last_name or "",
"address_line1": current_user.address_line1 or "",
"address_line2": current_user.address_line2,
"city": current_user.city or "",
"state": current_user.state or "",
"postal_code": current_user.postal_code or "",
"country": current_user.country or "",
"phone_number": current_user.phone_number,
"email": current_user.email
}
billing_address = shipping_address
else:
shipping_address = order_in.shipping_address.dict()
billing_address = order_in.billing_address.dict() if order_in.billing_address else shipping_address
# Create order items and calculate totals
order_items_data = []
for cart_item in cart_items:
# Validate product availability and stock
product = db.query(Product).filter(Product.id == cart_item.product_id).first()
if not product:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Product not found for cart item {cart_item.id}"
)
if product.status != ProductStatus.PUBLISHED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Product '{product.name}' is not available for purchase"
)
if product.stock_quantity < cart_item.quantity:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Not enough stock for product '{product.name}'. Available: {product.stock_quantity}"
)
# Calculate item subtotal and tax
item_unit_price = product.current_price
item_subtotal = item_unit_price * cart_item.quantity
item_tax = item_subtotal * (product.tax_rate / 100)
# Create order item
order_item_data = {
"product_id": product.id,
"quantity": cart_item.quantity,
"unit_price": item_unit_price,
"subtotal": item_subtotal,
"tax_amount": item_tax,
"product_name": product.name,
"product_sku": product.sku,
"product_options": cart_item.custom_properties
}
order_items_data.append(order_item_data)
# Update totals
subtotal += item_subtotal
tax_amount += item_tax
# Update product stock
product.stock_quantity -= cart_item.quantity
# Check if product is now out of stock
if product.stock_quantity == 0:
product.status = ProductStatus.OUT_OF_STOCK
# Calculate final total
total_amount = subtotal + tax_amount + shipping_amount - discount_amount
# Create the order
db_order = Order(
user_id=current_user.id,
order_number=generate_order_number(),
status=OrderStatus.PENDING,
total_amount=total_amount,
subtotal=subtotal,
tax_amount=tax_amount,
shipping_amount=shipping_amount,
discount_amount=discount_amount,
shipping_method=order_in.shipping_method,
shipping_address=json.dumps(shipping_address),
billing_address=json.dumps(billing_address),
notes=order_in.notes
)
db.add(db_order)
db.commit()
db.refresh(db_order)
# Create order items
for item_data in order_items_data:
db_order_item = OrderItem(
order_id=db_order.id,
**item_data
)
db.add(db_order_item)
# Remove cart items
for cart_item_id in cart_item_ids:
db.query(CartItem).filter(CartItem.id == cart_item_id).delete()
db.commit()
db.refresh(db_order)
logger.info(f"Order created: {db_order.order_number} (ID: {db_order.id}) for user {current_user.email}")
return db_order
@router.put("/{order_id}", response_model=OrderSchema)
async def update_order(
order_id: str,
order_in: OrderUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Update an order.
Regular users can only update their own orders and with limited fields.
Admins can update any order with all fields.
"""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Order not found"
)
# Check permissions
if current_user.role != UserRole.ADMIN and order.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to update this order"
)
# Regular users can only cancel pending orders
if current_user.role != UserRole.ADMIN:
if order.status != OrderStatus.PENDING:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only pending orders can be updated"
)
if order_in.status and order_in.status != OrderStatus.CANCELLED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You can only cancel an order"
)
# Update order attributes
for key, value in order_in.dict(exclude_unset=True).items():
setattr(order, key, value)
db.commit()
db.refresh(order)
logger.info(f"Order updated: {order.order_number} (ID: {order.id})")
return order
@router.delete("/{order_id}", response_model=dict)
async def delete_order(
order_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Delete an order (admin only).
"""
order = db.query(Order).filter(Order.id == order_id).first()
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Order not found"
)
# Delete order items first (should happen automatically with cascade)
db.query(OrderItem).filter(OrderItem.order_id == order_id).delete()
# Delete the order
db.delete(order)
db.commit()
logger.info(f"Order deleted: {order.order_number} (ID: {order.id})")
return {"message": "Order successfully deleted"}

244
app/routers/payments.py Normal file
View File

@ -0,0 +1,244 @@
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.dependencies.auth import get_current_active_user, get_current_admin
from app.models.order import Order, OrderStatus
from app.models.payment import Payment, PaymentMethod, PaymentStatus
from app.models.user import User, UserRole
from app.schemas.payment import (
Payment as PaymentSchema,
)
from app.schemas.payment import (
PaymentCreate,
PaymentResponse,
PaymentUpdate,
)
from app.services.payment import PaymentService
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/", response_model=list[PaymentSchema])
async def get_payments(
skip: int = 0,
limit: int = 100,
order_id: str | None = None,
status: PaymentStatus | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Get payments.
Regular users can only see their own payments.
Admins can see all payments and filter by user ID.
"""
query = db.query(Payment)
# Join with orders to filter by user
if current_user.role != UserRole.ADMIN:
query = query.join(Order).filter(Order.user_id == current_user.id)
# Filter by order ID if provided
if order_id:
query = query.filter(Payment.order_id == order_id)
# Filter by status if provided
if status:
query = query.filter(Payment.status == status)
# Apply pagination
payments = query.offset(skip).limit(limit).all()
# Ensure payment details are parsed as JSON
for payment in payments:
if payment.payment_details and isinstance(payment.payment_details, str):
try:
payment.payment_details = json.loads(payment.payment_details)
except:
payment.payment_details = {}
return payments
@router.get("/{payment_id}", response_model=PaymentSchema)
async def get_payment(
payment_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Get a specific payment by ID.
Regular users can only get their own payments.
Admins can get any payment.
"""
payment = db.query(Payment).filter(Payment.id == payment_id).first()
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found"
)
# Check if the payment belongs to the user (if not admin)
if current_user.role != UserRole.ADMIN:
order = db.query(Order).filter(Order.id == payment.order_id).first()
if not order or order.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to access this payment"
)
# Parse payment details as JSON
if payment.payment_details and isinstance(payment.payment_details, str):
try:
payment.payment_details = json.loads(payment.payment_details)
except:
payment.payment_details = {}
return payment
@router.post("/process", response_model=PaymentResponse)
async def process_payment(
payment_in: PaymentCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Process a payment for an order.
"""
# Check if order exists
order = db.query(Order).filter(Order.id == payment_in.order_id).first()
if not order:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Order not found"
)
# Check if the order belongs to the user (if not admin)
if current_user.role != UserRole.ADMIN and order.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to process payment for this order"
)
# Check if order is in a state that can be paid
if order.status != OrderStatus.PENDING and order.status != OrderStatus.FAILED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot process payment for order with status {order.status.value}"
)
# Process payment based on payment method
try:
if payment_in.payment_method == PaymentMethod.STRIPE:
result = await PaymentService.process_stripe_payment(
db, order, payment_in.payment_details
)
elif payment_in.payment_method == PaymentMethod.PAYPAL:
result = await PaymentService.process_paypal_payment(
db, order, payment_in.payment_details
)
else:
# Process generic payment
result = await PaymentService.process_payment(
db, order, payment_in.payment_method, payment_in.payment_details
)
logger.info(f"Payment processed for order {order.id}: {result}")
return result
except Exception as e:
logger.error(f"Error processing payment: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error processing payment: {str(e)}"
)
@router.post("/verify/{payment_id}", response_model=PaymentResponse)
async def verify_payment(
payment_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Verify a payment status.
"""
payment = db.query(Payment).filter(Payment.id == payment_id).first()
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found"
)
# Check if the payment belongs to the user (if not admin)
if current_user.role != UserRole.ADMIN:
order = db.query(Order).filter(Order.id == payment.order_id).first()
if not order or order.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to verify this payment"
)
# Verify payment
updated_payment = await PaymentService.verify_payment(db, payment_id)
return {
"success": updated_payment.status == PaymentStatus.COMPLETED,
"payment_id": updated_payment.id,
"status": updated_payment.status,
"transaction_id": updated_payment.transaction_id,
"error_message": updated_payment.error_message,
}
@router.put("/{payment_id}", response_model=PaymentSchema)
async def update_payment(
payment_id: str,
payment_in: PaymentUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Update a payment (admin only).
"""
payment = db.query(Payment).filter(Payment.id == payment_id).first()
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found"
)
# Update payment attributes
update_data = payment_in.dict(exclude_unset=True)
# Convert payment_details to JSON string if provided
if "payment_details" in update_data and update_data["payment_details"] is not None:
update_data["payment_details"] = json.dumps(update_data["payment_details"])
for key, value in update_data.items():
setattr(payment, key, value)
# If payment status is changing, update order status as well
if payment_in.status and payment_in.status != payment.status:
order = db.query(Order).filter(Order.id == payment.order_id).first()
if order:
if payment_in.status == PaymentStatus.COMPLETED:
order.status = OrderStatus.PROCESSING
elif payment_in.status == PaymentStatus.FAILED:
order.status = OrderStatus.PENDING
elif payment_in.status == PaymentStatus.REFUNDED:
order.status = OrderStatus.REFUNDED
db.commit()
db.refresh(payment)
# Parse payment details for response
if payment.payment_details and isinstance(payment.payment_details, str):
try:
payment.payment_details = json.loads(payment.payment_details)
except:
payment.payment_details = {}
logger.info(f"Payment updated: ID {payment_id}")
return payment

441
app/routers/products.py Normal file
View File

@ -0,0 +1,441 @@
import logging
import os
import re
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, File, HTTPException, Path, UploadFile, status
from sqlalchemy import or_
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.dependencies.auth import get_current_active_user, get_current_seller
from app.models.category import Category
from app.models.product import Product, ProductImage, ProductStatus
from app.models.tag import Tag
from app.models.user import User, UserRole
from app.schemas.product import (
Product as ProductSchema,
)
from app.schemas.product import (
ProductCreate,
ProductDetails,
ProductUpdate,
)
from app.schemas.product import (
ProductImage as ProductImageSchema,
)
router = APIRouter()
logger = logging.getLogger(__name__)
def slugify(text):
"""Convert a string to a URL-friendly slug."""
# Remove non-alphanumeric characters
text = re.sub(r'[^\w\s-]', '', text.lower())
# Replace spaces with hyphens
text = re.sub(r'[\s]+', '-', text)
# Remove consecutive hyphens
text = re.sub(r'[-]+', '-', text)
# Add timestamp to ensure uniqueness
timestamp = int(datetime.now().timestamp())
return f"{text}-{timestamp}"
@router.get("/", response_model=list[ProductSchema])
async def get_products(
skip: int = 0,
limit: int = 100,
category_id: str | None = None,
status: ProductStatus | None = None,
search: str | None = None,
min_price: float | None = None,
max_price: float | None = None,
featured: bool | None = None,
db: Session = Depends(get_db)
):
"""
Get all products with optional filtering.
"""
query = db.query(Product)
# Apply filters
if category_id:
query = query.filter(Product.category_id == category_id)
if status:
query = query.filter(Product.status == status)
else:
# By default, only show published products
query = query.filter(Product.status == ProductStatus.PUBLISHED)
if search:
search_term = f"%{search}%"
query = query.filter(
or_(
Product.name.ilike(search_term),
Product.description.ilike(search_term),
Product.sku.ilike(search_term)
)
)
if min_price is not None:
query = query.filter(Product.price >= min_price)
if max_price is not None:
query = query.filter(Product.price <= max_price)
if featured is not None:
query = query.filter(Product.is_featured == featured)
# Apply pagination
products = query.offset(skip).limit(limit).all()
# Enhance products with category name and tags
for product in products:
if product.category:
product.category_name = product.category.name
product.tags = [tag.name for tag in product.tags]
return products
@router.get("/{product_id}", response_model=ProductDetails)
async def get_product(
product_id: str = Path(..., title="The ID of the product to get"),
db: Session = Depends(get_db)
):
"""
Get a specific product by ID.
"""
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
# Add category name if available
if product.category:
product.category_name = product.category.name
# Add tags
product.tags = [tag.name for tag in product.tags]
# Calculate in_stock
product.in_stock = product.stock_quantity > 0
# For seller/admin, add sales data
product.total_sales = 0
product.total_revenue = 0
for order_item in product.order_items:
product.total_sales += order_item.quantity
product.total_revenue += order_item.subtotal
return product
@router.post("/", response_model=ProductSchema)
async def create_product(
product_in: ProductCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_seller)
):
"""
Create a new product (seller or admin only).
"""
# Check if slug already exists
existing_product = db.query(Product).filter(Product.slug == product_in.slug).first()
if existing_product:
# If slug exists, create a unique one
product_in.slug = slugify(product_in.name)
# Check if category exists
if product_in.category_id:
category = db.query(Category).filter(Category.id == product_in.category_id).first()
if not category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Category not found"
)
# Create new product
db_product = Product(
name=product_in.name,
description=product_in.description,
price=product_in.price,
sku=product_in.sku,
barcode=product_in.barcode,
stock_quantity=product_in.stock_quantity,
weight=product_in.weight,
dimensions=product_in.dimensions,
status=product_in.status,
is_featured=product_in.is_featured,
is_digital=product_in.is_digital,
digital_download_link=product_in.digital_download_link,
slug=product_in.slug,
tax_rate=product_in.tax_rate,
discount_price=product_in.discount_price,
discount_start_date=product_in.discount_start_date,
discount_end_date=product_in.discount_end_date,
category_id=product_in.category_id,
seller_id=current_user.id,
)
db.add(db_product)
db.commit()
db.refresh(db_product)
# Add images if provided
if product_in.images:
for image_data in product_in.images:
db_image = ProductImage(
product_id=db_product.id,
image_url=image_data.image_url,
alt_text=image_data.alt_text,
is_primary=image_data.is_primary,
display_order=image_data.display_order
)
db.add(db_image)
db.commit()
# Add tags if provided
if product_in.tag_ids:
for tag_id in product_in.tag_ids:
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if tag:
db_product.tags.append(tag)
db.commit()
db.refresh(db_product)
logger.info(f"Product created: {db_product.name} (ID: {db_product.id})")
return db_product
@router.put("/{product_id}", response_model=ProductSchema)
async def update_product(
product_id: str,
product_in: ProductUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Update a product.
Sellers can only update their own products.
Admins can update any product.
"""
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
# Check permissions
if current_user.role != UserRole.ADMIN and current_user.id != product.seller_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to update this product"
)
# Check if slug already exists (if updating slug)
if product_in.slug and product_in.slug != product.slug:
existing_product = db.query(Product).filter(Product.slug == product_in.slug).first()
if existing_product:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Slug already exists"
)
# Check if category exists (if updating category)
if product_in.category_id:
category = db.query(Category).filter(Category.id == product_in.category_id).first()
if not category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Category not found"
)
# Update product attributes
for key, value in product_in.dict(exclude_unset=True).items():
if key != "tag_ids": # Handle tags separately
setattr(product, key, value)
# Update tags if provided
if product_in.tag_ids is not None:
# Clear existing tags
product.tags = []
# Add new tags
for tag_id in product_in.tag_ids:
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if tag:
product.tags.append(tag)
db.commit()
db.refresh(product)
logger.info(f"Product updated: {product.name} (ID: {product.id})")
return product
@router.delete("/{product_id}", response_model=dict)
async def delete_product(
product_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Delete a product.
Sellers can only delete their own products.
Admins can delete any product.
"""
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
# Check permissions
if current_user.role != UserRole.ADMIN and current_user.id != product.seller_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to delete this product"
)
# Delete the product
db.delete(product)
db.commit()
logger.info(f"Product deleted: {product.name} (ID: {product.id})")
return {"message": "Product successfully deleted"}
@router.post("/{product_id}/images", response_model=ProductImageSchema)
async def upload_product_image(
product_id: str,
file: UploadFile = File(...),
is_primary: bool = False,
alt_text: str = None,
display_order: int = 0,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Upload an image for a product.
Sellers can only upload images for their own products.
Admins can upload images for any product.
"""
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
# Check permissions
if current_user.role != UserRole.ADMIN and current_user.id != product.seller_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to update this product"
)
# Validate file
content_type = file.content_type
if not content_type.startswith("image/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an image"
)
# Create product images directory if it doesn't exist
product_images_dir = settings.PRODUCT_IMAGES_DIR
product_images_dir.mkdir(parents=True, exist_ok=True)
# Generate unique filename
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{uuid.uuid4()}{file_extension}"
file_path = product_images_dir / unique_filename
# Save the file
with open(file_path, "wb") as buffer:
buffer.write(await file.read())
# If this is the primary image, update other images
if is_primary:
db.query(ProductImage).filter(
ProductImage.product_id == product_id,
ProductImage.is_primary == True
).update({"is_primary": False})
# Create the image record
relative_path = f"/storage/product_images/{unique_filename}"
db_image = ProductImage(
product_id=product_id,
image_url=relative_path,
alt_text=alt_text,
is_primary=is_primary,
display_order=display_order
)
db.add(db_image)
db.commit()
db.refresh(db_image)
logger.info(f"Image uploaded for product ID {product_id}: {unique_filename}")
return db_image
@router.delete("/{product_id}/images/{image_id}", response_model=dict)
async def delete_product_image(
product_id: str,
image_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Delete a product image.
Sellers can only delete images for their own products.
Admins can delete images for any product.
"""
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
# Check permissions
if current_user.role != UserRole.ADMIN and current_user.id != product.seller_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to update this product"
)
# Find the image
image = db.query(ProductImage).filter(
ProductImage.id == image_id,
ProductImage.product_id == product_id
).first()
if not image:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Image not found"
)
# Delete the image from the database
db.delete(image)
db.commit()
# Try to delete the physical file (if it exists)
try:
file_name = os.path.basename(image.image_url)
file_path = settings.PRODUCT_IMAGES_DIR / file_name
if os.path.exists(file_path):
os.remove(file_path)
except Exception as e:
# Log the error but don't fail the request
logger.error(f"Error deleting image file: {str(e)}")
logger.info(f"Image deleted for product ID {product_id}: {image_id}")
return {"message": "Product image successfully deleted"}

270
app/routers/reviews.py Normal file
View File

@ -0,0 +1,270 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import desc
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.dependencies.auth import get_current_active_user
from app.models.order import Order, OrderStatus
from app.models.product import Product
from app.models.review import Review
from app.models.user import User, UserRole
from app.schemas.review import (
Review as ReviewSchema,
)
from app.schemas.review import (
ReviewCreate,
ReviewUpdate,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/", response_model=list[ReviewSchema])
async def get_reviews(
product_id: str | None = None,
user_id: str | None = None,
approved_only: bool = True,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
):
"""
Get reviews.
Filter by product_id or user_id.
Regular users can only see approved reviews for products.
"""
query = db.query(Review)
# Filter by product ID if provided
if product_id:
query = query.filter(Review.product_id == product_id)
# Filter by user ID if provided
if user_id:
query = query.filter(Review.user_id == user_id)
# Filter by approved status
if approved_only:
query = query.filter(Review.is_approved == True)
# Apply pagination and order by newest first
reviews = query.order_by(desc(Review.created_at)).offset(skip).limit(limit).all()
# Enhance reviews with user and product names
for review in reviews:
# Add user name if available
if review.user and review.user.first_name:
if review.user.last_name:
review.user_name = f"{review.user.first_name} {review.user.last_name}"
else:
review.user_name = review.user.first_name
else:
review.user_name = "Anonymous"
# Add product name if available
if review.product:
review.product_name = review.product.name
return reviews
@router.get("/{review_id}", response_model=ReviewSchema)
async def get_review(
review_id: str,
db: Session = Depends(get_db),
):
"""
Get a specific review by ID.
"""
review = db.query(Review).filter(Review.id == review_id).first()
if not review:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Review not found"
)
# Add user name if available
if review.user and review.user.first_name:
if review.user.last_name:
review.user_name = f"{review.user.first_name} {review.user.last_name}"
else:
review.user_name = review.user.first_name
else:
review.user_name = "Anonymous"
# Add product name if available
if review.product:
review.product_name = review.product.name
return review
@router.post("/", response_model=ReviewSchema)
async def create_review(
review_in: ReviewCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Create a new review for a product.
User must have purchased the product to leave a verified review.
"""
# Check if product exists
product = db.query(Product).filter(Product.id == review_in.product_id).first()
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
# Check if user has already reviewed this product
existing_review = db.query(Review).filter(
Review.product_id == review_in.product_id,
Review.user_id == current_user.id
).first()
if existing_review:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You have already reviewed this product"
)
# Check if the user has purchased the product for verified review status
is_verified_purchase = False
# Query completed orders
completed_orders = db.query(Order).filter(
Order.user_id == current_user.id,
Order.status.in_([OrderStatus.DELIVERED, OrderStatus.COMPLETED])
).all()
# Check if any of these orders contain the product
for order in completed_orders:
for item in order.items:
if item.product_id == review_in.product_id:
is_verified_purchase = True
break
if is_verified_purchase:
break
# Admin reviews are always approved, regular user reviews may need approval
is_approved = True if current_user.role == UserRole.ADMIN else True # Auto-approve for now
# Create the review
db_review = Review(
product_id=review_in.product_id,
user_id=current_user.id,
rating=review_in.rating,
title=review_in.title,
comment=review_in.comment,
is_verified_purchase=is_verified_purchase,
is_approved=is_approved
)
db.add(db_review)
db.commit()
db.refresh(db_review)
# Add user name and product name for the response
if current_user.first_name:
if current_user.last_name:
db_review.user_name = f"{current_user.first_name} {current_user.last_name}"
else:
db_review.user_name = current_user.first_name
else:
db_review.user_name = "Anonymous"
db_review.product_name = product.name
logger.info(f"Review created: ID {db_review.id} for product {product.name}")
return db_review
@router.put("/{review_id}", response_model=ReviewSchema)
async def update_review(
review_id: str,
review_in: ReviewUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Update a review.
Users can only update their own reviews.
Admins can update any review.
"""
review = db.query(Review).filter(Review.id == review_id).first()
if not review:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Review not found"
)
# Check permissions
is_admin = current_user.role == UserRole.ADMIN
is_owner = review.user_id == current_user.id
if not is_admin and not is_owner:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to update this review"
)
# Regular users cannot update approval status
update_data = review_in.dict(exclude_unset=True)
if not is_admin and "is_approved" in update_data:
del update_data["is_approved"]
# Update review attributes
for key, value in update_data.items():
setattr(review, key, value)
db.commit()
db.refresh(review)
# Add user name and product name for the response
if review.user and review.user.first_name:
if review.user.last_name:
review.user_name = f"{review.user.first_name} {review.user.last_name}"
else:
review.user_name = review.user.first_name
else:
review.user_name = "Anonymous"
if review.product:
review.product_name = review.product.name
logger.info(f"Review updated: ID {review.id}")
return review
@router.delete("/{review_id}", response_model=dict)
async def delete_review(
review_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Delete a review.
Users can only delete their own reviews.
Admins can delete any review.
"""
review = db.query(Review).filter(Review.id == review_id).first()
if not review:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Review not found"
)
# Check permissions
is_admin = current_user.role == UserRole.ADMIN
is_owner = review.user_id == current_user.id
if not is_admin and not is_owner:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to delete this review"
)
db.delete(review)
db.commit()
logger.info(f"Review deleted: ID {review.id}")
return {"message": "Review successfully deleted"}

68
app/routers/search.py Normal file
View File

@ -0,0 +1,68 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.models.product import ProductStatus
from app.schemas.product import Product as ProductSchema
from app.services.search import SearchService
router = APIRouter()
@router.get("/products", response_model=dict)
async def search_products(
query: str | None = None,
category_id: str | None = None,
tag_ids: list[str] | None = Query(None),
min_price: float | None = None,
max_price: float | None = None,
min_rating: int | None = Query(None, ge=1, le=5),
status: ProductStatus | None = ProductStatus.PUBLISHED,
sort_by: str = "relevance",
sort_order: str = "desc",
is_featured: bool | None = None,
seller_id: str | None = None,
offset: int = 0,
limit: int = 100,
db: Session = Depends(get_db)
):
"""
Search and filter products with advanced options.
- **query**: Text to search in product name and description
- **category_id**: Filter by category ID
- **tag_ids**: Filter by tag IDs (products must have ALL specified tags)
- **min_price**: Minimum price filter
- **max_price**: Maximum price filter
- **min_rating**: Minimum average rating filter (1-5)
- **status**: Filter by product status (admin only sees non-published)
- **sort_by**: Field to sort by (name, price, created_at, rating, relevance)
- **sort_order**: Sort order (asc or desc)
- **is_featured**: Filter by featured status
- **seller_id**: Filter by seller ID
- **offset**: Pagination offset
- **limit**: Pagination limit
"""
search_results = SearchService.search_products(
db=db,
search_query=query,
category_id=category_id,
tag_ids=tag_ids,
min_price=min_price,
max_price=max_price,
min_rating=min_rating,
status=status,
sort_by=sort_by,
sort_order=sort_order,
is_featured=is_featured,
seller_id=seller_id,
offset=offset,
limit=limit
)
# Convert SQLAlchemy objects to Pydantic models
search_results["products"] = [
ProductSchema.from_orm(product) for product in search_results["products"]
]
return search_results

185
app/routers/tags.py Normal file
View File

@ -0,0 +1,185 @@
import logging
import re
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.dependencies.auth import get_current_admin
from app.models.tag import ProductTag, Tag
from app.models.user import User
from app.schemas.tag import (
Tag as TagSchema,
)
from app.schemas.tag import (
TagCreate,
TagUpdate,
TagWithProductCount,
)
router = APIRouter()
logger = logging.getLogger(__name__)
def slugify(text):
"""Convert a string to a URL-friendly slug."""
# Remove non-alphanumeric characters
text = re.sub(r'[^\w\s-]', '', text.lower())
# Replace spaces with hyphens
text = re.sub(r'[\s]+', '-', text)
# Remove consecutive hyphens
text = re.sub(r'[-]+', '-', text)
# Add timestamp to ensure uniqueness
timestamp = int(datetime.now().timestamp())
return f"{text}-{timestamp}"
@router.get("/", response_model=list[TagSchema])
async def get_tags(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db)
):
"""
Get all tags.
"""
tags = db.query(Tag).offset(skip).limit(limit).all()
return tags
@router.get("/with-count", response_model=list[TagWithProductCount])
async def get_tags_with_product_count(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db)
):
"""
Get all tags with product counts.
"""
tags = db.query(Tag).offset(skip).limit(limit).all()
# Add product count to each tag
for tag in tags:
tag.product_count = db.query(ProductTag).filter(ProductTag.tag_id == tag.id).count()
return tags
@router.get("/{tag_id}", response_model=TagSchema)
async def get_tag(
tag_id: str,
db: Session = Depends(get_db)
):
"""
Get a specific tag by ID.
"""
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tag not found"
)
return tag
@router.post("/", response_model=TagSchema)
async def create_tag(
tag_in: TagCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Create a new tag (admin only).
"""
# Check if name already exists
existing_tag_name = db.query(Tag).filter(func.lower(Tag.name) == tag_in.name.lower()).first()
if existing_tag_name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A tag with this name already exists"
)
# Check if slug already exists
existing_tag_slug = db.query(Tag).filter(Tag.slug == tag_in.slug).first()
if existing_tag_slug:
# If slug exists, create a unique one
tag_in.slug = slugify(tag_in.name)
# Create new tag
db_tag = Tag(
name=tag_in.name,
slug=tag_in.slug
)
db.add(db_tag)
db.commit()
db.refresh(db_tag)
logger.info(f"Tag created: {db_tag.name} (ID: {db_tag.id})")
return db_tag
@router.put("/{tag_id}", response_model=TagSchema)
async def update_tag(
tag_id: str,
tag_in: TagUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Update a tag (admin only).
"""
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tag not found"
)
# Check if name already exists (if updating name)
if tag_in.name and tag_in.name.lower() != tag.name.lower():
existing_tag = db.query(Tag).filter(func.lower(Tag.name) == tag_in.name.lower()).first()
if existing_tag:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A tag with this name already exists"
)
# Check if slug already exists (if updating slug)
if tag_in.slug and tag_in.slug != tag.slug:
existing_tag = db.query(Tag).filter(Tag.slug == tag_in.slug).first()
if existing_tag:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Slug already exists"
)
# Update tag attributes
for key, value in tag_in.dict(exclude_unset=True).items():
setattr(tag, key, value)
db.commit()
db.refresh(tag)
logger.info(f"Tag updated: {tag.name} (ID: {tag.id})")
return tag
@router.delete("/{tag_id}", response_model=dict)
async def delete_tag(
tag_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Delete a tag (admin only).
"""
tag = db.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tag not found"
)
# Delete the tag
db.delete(tag)
db.commit()
logger.info(f"Tag deleted: {tag.name} (ID: {tag.id})")
return {"message": "Tag successfully deleted"}

172
app/routers/users.py Normal file
View File

@ -0,0 +1,172 @@
import logging
import os
import uuid
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_password_hash, verify_password
from app.dependencies.auth import get_current_active_user, get_current_admin
from app.models.user import User, UserRole
from app.schemas.user import (
User as UserSchema,
)
from app.schemas.user import (
UserPasswordChange,
UserUpdate,
UserWithAddress,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/", response_model=list[UserSchema])
async def get_users(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""
Get all users (admin only).
"""
users = db.query(User).offset(skip).limit(limit).all()
return users
@router.get("/{user_id}", response_model=UserWithAddress)
async def get_user(
user_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Get a specific user by ID.
Regular users can only get their own user details.
Admin users can get any user details.
"""
if current_user.id != user_id and current_user.role != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to access this user's data"
)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return user
@router.put("/me", response_model=UserWithAddress)
async def update_user_me(
user_update: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Update current user information.
"""
for key, value in user_update.dict(exclude_unset=True).items():
setattr(current_user, key, value)
db.commit()
db.refresh(current_user)
logger.info(f"User {current_user.email} updated their profile")
return current_user
@router.post("/me/change-password", response_model=dict)
async def change_password(
password_data: UserPasswordChange,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Change current user's password.
"""
if not verify_password(password_data.current_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect current password"
)
current_user.hashed_password = get_password_hash(password_data.new_password)
db.commit()
logger.info(f"User {current_user.email} changed their password")
return {"message": "Password updated successfully"}
@router.post("/me/profile-image", response_model=dict)
async def upload_profile_image(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Upload a profile image for the current user.
"""
# Validate the file
content_type = file.content_type
if not content_type.startswith("image/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an image"
)
# Create user images directory if it doesn't exist
user_images_dir = settings.USER_IMAGES_DIR
user_images_dir.mkdir(parents=True, exist_ok=True)
# Generate a unique filename
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{uuid.uuid4()}{file_extension}"
file_path = user_images_dir / unique_filename
# Save the file
with open(file_path, "wb") as buffer:
buffer.write(await file.read())
# Update the user's profile image in the database
relative_path = f"/storage/user_images/{unique_filename}"
current_user.profile_image = relative_path
db.commit()
logger.info(f"User {current_user.email} uploaded a new profile image")
return {"filename": unique_filename, "path": relative_path}
@router.delete("/{user_id}", response_model=dict)
async def delete_user(
user_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""
Delete a user.
Regular users can only delete their own account.
Admin users can delete any user.
"""
if current_user.id != user_id and current_user.role != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to delete this user"
)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Soft delete the user by setting is_active to False
# In a real-world application, you might want to consider:
# 1. Hard deleting the user data for GDPR compliance
# 2. Anonymizing the user data instead of deleting
# 3. Setting up a scheduled task to actually delete inactive users after a certain period
user.is_active = False
db.commit()
logger.info(f"User {user.email} was marked as inactive (deleted)")
return {"message": "User successfully deleted"}

1
app/schemas/__init__.py Normal file
View File

@ -0,0 +1 @@
# Pydantic schemas/models for request and response handling

79
app/schemas/admin.py Normal file
View File

@ -0,0 +1,79 @@
from datetime import date
from enum import Enum
from pydantic import BaseModel
class TimePeriod(str, Enum):
TODAY = "today"
YESTERDAY = "yesterday"
LAST_7_DAYS = "last_7_days"
LAST_30_DAYS = "last_30_days"
THIS_MONTH = "this_month"
LAST_MONTH = "last_month"
THIS_YEAR = "this_year"
ALL_TIME = "all_time"
class SalesSummary(BaseModel):
period: TimePeriod
total_sales: float
total_orders: int
average_order_value: float
refunded_amount: float
class DateSales(BaseModel):
date: date
total_sales: float
order_count: int
class SalesOverTime(BaseModel):
period: TimePeriod
data: list[DateSales]
total_sales: float
total_orders: int
class CategorySales(BaseModel):
category_id: str
category_name: str
total_sales: float
percentage: float
class TopCategorySales(BaseModel):
period: TimePeriod
categories: list[CategorySales]
total_sales: float
class ProductSales(BaseModel):
product_id: str
product_name: str
quantity_sold: int
total_sales: float
class TopProductSales(BaseModel):
period: TimePeriod
products: list[ProductSales]
total_sales: float
class CustomerSales(BaseModel):
user_id: str
user_name: str
order_count: int
total_spent: float
class TopCustomerSales(BaseModel):
period: TimePeriod
customers: list[CustomerSales]
total_sales: float
class DashboardSummary(BaseModel):
sales_summary: SalesSummary
pending_orders: int
low_stock_products: int
new_customers: int
total_products: int
total_customers: int
class OrdersPerStatus(BaseModel):
status: str
count: int
percentage: float

49
app/schemas/cart.py Normal file
View File

@ -0,0 +1,49 @@
import json
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field, validator
class CartItemBase(BaseModel):
product_id: str
quantity: int = Field(..., gt=0)
custom_properties: dict[str, Any] | None = None
class CartItemCreate(CartItemBase):
pass
class CartItemUpdate(BaseModel):
quantity: int | None = Field(None, gt=0)
custom_properties: dict[str, Any] | None = None
class CartItemInDBBase(CartItemBase):
id: str
user_id: str
price_at_addition: float
created_at: datetime
updated_at: datetime | None = None
class Config:
orm_mode = True
@validator('custom_properties', pre=True)
def parse_custom_properties(cls, v):
if isinstance(v, str):
try:
return json.loads(v)
except:
return None
return v
class CartItem(CartItemInDBBase):
product_name: str
product_image: str | None = None
current_price: float
subtotal: float
class CartSummary(BaseModel):
items: list[CartItem]
total_items: int
subtotal: float
total_weight: float | None = None

40
app/schemas/category.py Normal file
View File

@ -0,0 +1,40 @@
from datetime import datetime
from pydantic import BaseModel
class CategoryBase(BaseModel):
name: str
slug: str
description: str | None = None
image: str | None = None
parent_id: str | None = None
is_active: bool | None = True
display_order: int | None = 0
class CategoryCreate(CategoryBase):
pass
class CategoryUpdate(BaseModel):
name: str | None = None
slug: str | None = None
description: str | None = None
image: str | None = None
parent_id: str | None = None
is_active: bool | None = None
display_order: int | None = None
class CategoryInDBBase(CategoryBase):
id: str
created_at: datetime
updated_at: datetime | None = None
class Config:
orm_mode = True
class Category(CategoryInDBBase):
pass
class CategoryWithChildren(Category):
subcategories: list['CategoryWithChildren'] = []
product_count: int = 0

107
app/schemas/order.py Normal file
View File

@ -0,0 +1,107 @@
import json
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field, validator
from app.models.order import OrderStatus, ShippingMethod
class AddressSchema(BaseModel):
first_name: str
last_name: str
address_line1: str
address_line2: str | None = None
city: str
state: str
postal_code: str
country: str
phone_number: str | None = None
email: str | None = None
class OrderItemBase(BaseModel):
product_id: str
quantity: int = Field(..., gt=0)
unit_price: float
product_options: dict[str, Any] | None = None
class OrderItemCreate(OrderItemBase):
pass
class OrderItemInDB(OrderItemBase):
id: str
order_id: str
subtotal: float
discount: float = 0.0
tax_amount: float = 0.0
product_name: str
product_sku: str | None = None
created_at: datetime
class Config:
orm_mode = True
@validator('product_options', pre=True)
def parse_product_options(cls, v):
if isinstance(v, str):
try:
return json.loads(v)
except:
return None
return v
class OrderBase(BaseModel):
shipping_method: ShippingMethod
shipping_address: AddressSchema
billing_address: AddressSchema | None = None
notes: str | None = None
class OrderCreate(OrderBase):
cart_items: list[str] = [] # List of cart item IDs
coupon_code: str | None = None
use_default_addresses: bool = False
class OrderUpdate(BaseModel):
status: OrderStatus | None = None
tracking_number: str | None = None
notes: str | None = None
class OrderInDBBase(OrderBase):
id: str
user_id: str
order_number: str
status: OrderStatus
total_amount: float
subtotal: float
tax_amount: float
shipping_amount: float
discount_amount: float = 0.0
tracking_number: str | None = None
created_at: datetime
updated_at: datetime | None = None
class Config:
orm_mode = True
@validator('shipping_address', 'billing_address', pre=True)
def parse_address(cls, v):
if isinstance(v, str):
try:
return json.loads(v)
except:
return {}
return v
class Order(OrderInDBBase):
items: list[OrderItemInDB] = []
class OrderSummary(BaseModel):
id: str
order_number: str
status: OrderStatus
total_amount: float
created_at: datetime
item_count: int
class Config:
orm_mode = True

57
app/schemas/payment.py Normal file
View File

@ -0,0 +1,57 @@
import json
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
from app.models.payment import PaymentMethod, PaymentStatus
class PaymentBase(BaseModel):
order_id: str
amount: float = Field(..., gt=0)
payment_method: PaymentMethod
class PaymentCreate(PaymentBase):
payment_details: dict[str, Any] = {}
class PaymentUpdate(BaseModel):
status: PaymentStatus | None = None
transaction_id: str | None = None
payment_details: dict[str, Any] | None = None
error_message: str | None = None
class PaymentInDBBase(PaymentBase):
id: str
status: PaymentStatus
transaction_id: str | None = None
error_message: str | None = None
created_at: datetime
updated_at: datetime | None = None
class Config:
orm_mode = True
@property
def payment_details_parsed(self) -> dict[str, Any]:
if hasattr(self, 'payment_details') and self.payment_details:
if isinstance(self.payment_details, str):
try:
return json.loads(self.payment_details)
except:
return {}
return self.payment_details
return {}
class Payment(PaymentInDBBase):
payment_details: dict[str, Any] | None = None
class PaymentResponse(BaseModel):
"""Response model for payment processing endpoints"""
success: bool
payment_id: str | None = None
status: PaymentStatus | None = None
transaction_id: str | None = None
redirect_url: str | None = None
error_message: str | None = None

114
app/schemas/product.py Normal file
View File

@ -0,0 +1,114 @@
from datetime import datetime
from pydantic import BaseModel, Field, validator
from app.models.product import ProductStatus
class ProductImageBase(BaseModel):
image_url: str
alt_text: str | None = None
is_primary: bool | None = False
display_order: int | None = 0
class ProductImageCreate(ProductImageBase):
pass
class ProductImageUpdate(ProductImageBase):
image_url: str | None = None
class ProductImage(ProductImageBase):
id: str
product_id: str
created_at: datetime
class Config:
orm_mode = True
class ProductBase(BaseModel):
name: str
description: str | None = None
price: float = Field(..., gt=0)
sku: str | None = None
barcode: str | None = None
stock_quantity: int = Field(0, ge=0)
weight: float | None = None
dimensions: str | None = None
status: ProductStatus = ProductStatus.DRAFT
is_featured: bool | None = False
is_digital: bool | None = False
digital_download_link: str | None = None
slug: str
tax_rate: float | None = 0.0
discount_price: float | None = None
discount_start_date: datetime | None = None
discount_end_date: datetime | None = None
category_id: str | None = None
@validator('discount_price')
def discount_price_must_be_less_than_price(cls, v, values):
if v is not None and 'price' in values and v >= values['price']:
raise ValueError('Discount price must be less than regular price')
return v
@validator('discount_end_date')
def end_date_must_be_after_start_date(cls, v, values):
if (v is not None and 'discount_start_date' in values
and values['discount_start_date'] is not None
and v <= values['discount_start_date']):
raise ValueError('Discount end date must be after start date')
return v
class ProductCreate(ProductBase):
images: list[ProductImageCreate] | None = None
tag_ids: list[str] | None = []
class ProductUpdate(BaseModel):
name: str | None = None
description: str | None = None
price: float | None = Field(None, gt=0)
sku: str | None = None
barcode: str | None = None
stock_quantity: int | None = Field(None, ge=0)
weight: float | None = None
dimensions: str | None = None
status: ProductStatus | None = None
is_featured: bool | None = None
is_digital: bool | None = None
digital_download_link: str | None = None
slug: str | None = None
tax_rate: float | None = None
discount_price: float | None = None
discount_start_date: datetime | None = None
discount_end_date: datetime | None = None
category_id: str | None = None
tag_ids: list[str] | None = None
@validator('discount_price')
def discount_price_validation(cls, v, values):
if v is not None and 'price' in values and values['price'] is not None and v >= values['price']:
raise ValueError('Discount price must be less than regular price')
return v
class ProductInDBBase(ProductBase):
id: str
seller_id: str | None
created_at: datetime
updated_at: datetime | None
class Config:
orm_mode = True
class Product(ProductInDBBase):
images: list[ProductImage] = []
average_rating: float | None = None
current_price: float
category_name: str | None = None
tags: list[str] = []
class ProductDetails(Product):
"""Extended product details including inventory and sales data"""
total_sales: int | None = None
total_revenue: float | None = None
in_stock: bool

44
app/schemas/review.py Normal file
View File

@ -0,0 +1,44 @@
from datetime import datetime
from pydantic import BaseModel, Field, validator
class ReviewBase(BaseModel):
product_id: str
rating: int = Field(..., ge=1, le=5)
title: str | None = None
comment: str | None = None
class ReviewCreate(ReviewBase):
@validator('rating')
def rating_must_be_valid(cls, v):
if v < 1 or v > 5:
raise ValueError('Rating must be between 1 and 5')
return v
class ReviewUpdate(BaseModel):
rating: int | None = Field(None, ge=1, le=5)
title: str | None = None
comment: str | None = None
is_approved: bool | None = None
@validator('rating')
def rating_must_be_valid(cls, v):
if v is not None and (v < 1 or v > 5):
raise ValueError('Rating must be between 1 and 5')
return v
class ReviewInDBBase(ReviewBase):
id: str
user_id: str
is_verified_purchase: bool
is_approved: bool
created_at: datetime
updated_at: datetime | None = None
class Config:
orm_mode = True
class Review(ReviewInDBBase):
user_name: str | None = None
product_name: str | None = None

29
app/schemas/tag.py Normal file
View File

@ -0,0 +1,29 @@
from datetime import datetime
from pydantic import BaseModel
class TagBase(BaseModel):
name: str
slug: str
class TagCreate(TagBase):
pass
class TagUpdate(BaseModel):
name: str | None = None
slug: str | None = None
class TagInDBBase(TagBase):
id: str
created_at: datetime
updated_at: datetime | None = None
class Config:
orm_mode = True
class Tag(TagInDBBase):
pass
class TagWithProductCount(Tag):
product_count: int = 0

88
app/schemas/user.py Normal file
View File

@ -0,0 +1,88 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field, validator
from app.models.user import UserRole
class UserBase(BaseModel):
email: EmailStr
first_name: str | None = None
last_name: str | None = None
phone_number: str | None = None
class UserCreate(UserBase):
password: str = Field(..., min_length=8, max_length=100)
confirm_password: str
@validator('confirm_password')
def passwords_match(cls, v, values, **kwargs):
if 'password' in values and v != values['password']:
raise ValueError('Passwords do not match')
return v
class UserUpdate(BaseModel):
first_name: str | None = None
last_name: str | None = None
phone_number: str | None = None
email: EmailStr | None = None
profile_image: str | None = None
address_line1: str | None = None
address_line2: str | None = None
city: str | None = None
state: str | None = None
postal_code: str | None = None
country: str | None = None
bio: str | None = None
class UserPasswordChange(BaseModel):
current_password: str
new_password: str = Field(..., min_length=8, max_length=100)
confirm_password: str
@validator('confirm_password')
def passwords_match(cls, v, values, **kwargs):
if 'new_password' in values and v != values['new_password']:
raise ValueError('Passwords do not match')
return v
class UserInDBBase(UserBase):
id: str
is_active: bool
role: UserRole
created_at: datetime
updated_at: datetime | None = None
email_verified: bool
profile_image: str | None = None
class Config:
orm_mode = True
class User(UserInDBBase):
"""User model returned to clients"""
pass
class UserWithAddress(User):
"""User model with address information"""
address_line1: str | None = None
address_line2: str | None = None
city: str | None = None
state: str | None = None
postal_code: str | None = None
country: str | None = None
bio: str | None = None
class UserInDB(UserInDBBase):
"""User model stored in DB (includes hashed password)"""
hashed_password: str
class Token(BaseModel):
access_token: str
token_type: str
class TokenPayload(BaseModel):
sub: str
exp: datetime

1
app/services/__init__.py Normal file
View File

@ -0,0 +1 @@
# Service modules for business logic

437
app/services/admin.py Normal file
View File

@ -0,0 +1,437 @@
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.models.category import Category
from app.models.order import Order, OrderStatus
from app.models.product import Product, ProductStatus
from app.models.user import User, UserRole
from app.schemas.admin import TimePeriod
class AdminDashboardService:
"""
Service for admin dashboard analytics.
"""
@staticmethod
def get_date_range(period: TimePeriod) -> tuple:
"""
Get date range for a given time period.
Args:
period: Time period to get range for
Returns:
Tuple of (start_date, end_date)
"""
now = datetime.now()
end_date = now
if period == TimePeriod.TODAY:
start_date = datetime(now.year, now.month, now.day, 0, 0, 0)
elif period == TimePeriod.YESTERDAY:
yesterday = now - timedelta(days=1)
start_date = datetime(yesterday.year, yesterday.month, yesterday.day, 0, 0, 0)
end_date = datetime(yesterday.year, yesterday.month, yesterday.day, 23, 59, 59)
elif period == TimePeriod.LAST_7_DAYS:
start_date = now - timedelta(days=7)
elif period == TimePeriod.LAST_30_DAYS:
start_date = now - timedelta(days=30)
elif period == TimePeriod.THIS_MONTH:
start_date = datetime(now.year, now.month, 1)
elif period == TimePeriod.LAST_MONTH:
if now.month == 1:
start_date = datetime(now.year - 1, 12, 1)
end_date = datetime(now.year, now.month, 1) - timedelta(days=1)
else:
start_date = datetime(now.year, now.month - 1, 1)
end_date = datetime(now.year, now.month, 1) - timedelta(days=1)
elif period == TimePeriod.THIS_YEAR:
start_date = datetime(now.year, 1, 1)
else: # ALL_TIME
start_date = datetime(1900, 1, 1) # A long time ago
return start_date, end_date
@staticmethod
def get_sales_summary(db: Session, period: TimePeriod) -> dict[str, Any]:
"""
Get sales summary for a given time period.
Args:
db: Database session
period: Time period to get summary for
Returns:
Dictionary with sales summary data
"""
start_date, end_date = AdminDashboardService.get_date_range(period)
# Get completed orders in the date range
completed_statuses = [OrderStatus.DELIVERED, OrderStatus.COMPLETED]
orders = db.query(Order).filter(
Order.created_at.between(start_date, end_date),
Order.status.in_(completed_statuses)
).all()
# Get refunded orders in the date range
refunded_orders = db.query(Order).filter(
Order.created_at.between(start_date, end_date),
Order.status == OrderStatus.REFUNDED
).all()
# Calculate totals
total_sales = sum(order.total_amount for order in orders)
total_orders = len(orders)
average_order_value = total_sales / total_orders if total_orders > 0 else 0
refunded_amount = sum(order.total_amount for order in refunded_orders)
return {
"period": period,
"total_sales": total_sales,
"total_orders": total_orders,
"average_order_value": average_order_value,
"refunded_amount": refunded_amount
}
@staticmethod
def get_sales_over_time(db: Session, period: TimePeriod) -> dict[str, Any]:
"""
Get sales data over time for the given period.
Args:
db: Database session
period: Time period to get data for
Returns:
Dictionary with sales over time data
"""
start_date, end_date = AdminDashboardService.get_date_range(period)
# Determine the date grouping and format based on the period
date_format = "%Y-%m-%d" # Default daily format
delta = timedelta(days=1) # Default daily increment
if period in [TimePeriod.THIS_YEAR, TimePeriod.ALL_TIME]:
date_format = "%Y-%m" # Monthly format
# Generate all dates in the range
date_range = []
current_date = start_date
while current_date <= end_date:
date_range.append(current_date.strftime(date_format))
current_date += delta
# Get completed orders in the date range
completed_statuses = [OrderStatus.DELIVERED, OrderStatus.COMPLETED]
orders = db.query(Order).filter(
Order.created_at.between(start_date, end_date),
Order.status.in_(completed_statuses)
).all()
# Group orders by date
date_sales = {}
for date_str in date_range:
date_sales[date_str] = {"date": date_str, "total_sales": 0, "order_count": 0}
for order in orders:
date_str = order.created_at.strftime(date_format)
if date_str in date_sales:
date_sales[date_str]["total_sales"] += order.total_amount
date_sales[date_str]["order_count"] += 1
# Convert to list and sort by date
data = list(date_sales.values())
data.sort(key=lambda x: x["date"])
total_sales = sum(item["total_sales"] for item in data)
total_orders = sum(item["order_count"] for item in data)
return {
"period": period,
"data": data,
"total_sales": total_sales,
"total_orders": total_orders
}
@staticmethod
def get_top_categories(db: Session, period: TimePeriod, limit: int = 5) -> dict[str, Any]:
"""
Get top selling categories for the given period.
Args:
db: Database session
period: Time period to get data for
limit: Number of categories to return
Returns:
Dictionary with top category sales data
"""
start_date, end_date = AdminDashboardService.get_date_range(period)
# Get completed orders in the date range
completed_statuses = [OrderStatus.DELIVERED, OrderStatus.COMPLETED]
# This is a complex query that would involve joining multiple tables
# For simplicity, we'll use a more direct approach
# Get all order items from completed orders
orders = db.query(Order).filter(
Order.created_at.between(start_date, end_date),
Order.status.in_(completed_statuses)
).all()
# Collect category sales data
category_sales = {}
total_sales = 0
for order in orders:
for item in order.items:
product = db.query(Product).filter(Product.id == item.product_id).first()
if product and product.category_id:
category_id = product.category_id
category = db.query(Category).filter(Category.id == category_id).first()
if category:
if category_id not in category_sales:
category_sales[category_id] = {
"category_id": category_id,
"category_name": category.name,
"total_sales": 0
}
item_total = item.unit_price * item.quantity - item.discount
category_sales[category_id]["total_sales"] += item_total
total_sales += item_total
# Convert to list and sort by total sales
categories = list(category_sales.values())
categories.sort(key=lambda x: x["total_sales"], reverse=True)
# Calculate percentages and limit results
for category in categories:
category["percentage"] = (category["total_sales"] / total_sales * 100) if total_sales > 0 else 0
categories = categories[:limit]
return {
"period": period,
"categories": categories,
"total_sales": total_sales
}
@staticmethod
def get_top_products(db: Session, period: TimePeriod, limit: int = 5) -> dict[str, Any]:
"""
Get top selling products for the given period.
Args:
db: Database session
period: Time period to get data for
limit: Number of products to return
Returns:
Dictionary with top product sales data
"""
start_date, end_date = AdminDashboardService.get_date_range(period)
# Get completed orders in the date range
completed_statuses = [OrderStatus.DELIVERED, OrderStatus.COMPLETED]
# Get all order items from completed orders
orders = db.query(Order).filter(
Order.created_at.between(start_date, end_date),
Order.status.in_(completed_statuses)
).all()
# Collect product sales data
product_sales = {}
total_sales = 0
for order in orders:
for item in order.items:
product_id = item.product_id
if product_id not in product_sales:
product = db.query(Product).filter(Product.id == product_id).first()
product_name = item.product_name if item.product_name else (product.name if product else "Unknown Product")
product_sales[product_id] = {
"product_id": product_id,
"product_name": product_name,
"quantity_sold": 0,
"total_sales": 0
}
product_sales[product_id]["quantity_sold"] += item.quantity
item_total = item.unit_price * item.quantity - item.discount
product_sales[product_id]["total_sales"] += item_total
total_sales += item_total
# Convert to list and sort by total sales
products = list(product_sales.values())
products.sort(key=lambda x: x["total_sales"], reverse=True)
# Limit results
products = products[:limit]
return {
"period": period,
"products": products,
"total_sales": total_sales
}
@staticmethod
def get_top_customers(db: Session, period: TimePeriod, limit: int = 5) -> dict[str, Any]:
"""
Get top customers for the given period.
Args:
db: Database session
period: Time period to get data for
limit: Number of customers to return
Returns:
Dictionary with top customer data
"""
start_date, end_date = AdminDashboardService.get_date_range(period)
# Get completed orders in the date range
completed_statuses = [OrderStatus.DELIVERED, OrderStatus.COMPLETED]
# Get all orders from the period
orders = db.query(Order).filter(
Order.created_at.between(start_date, end_date),
Order.status.in_(completed_statuses)
).all()
# Collect customer data
customer_data = {}
total_sales = 0
for order in orders:
user_id = order.user_id
if user_id not in customer_data:
user = db.query(User).filter(User.id == user_id).first()
user_name = f"{user.first_name} {user.last_name}" if user and user.first_name else f"User {user_id}"
customer_data[user_id] = {
"user_id": user_id,
"user_name": user_name,
"order_count": 0,
"total_spent": 0
}
customer_data[user_id]["order_count"] += 1
customer_data[user_id]["total_spent"] += order.total_amount
total_sales += order.total_amount
# Convert to list and sort by total spent
customers = list(customer_data.values())
customers.sort(key=lambda x: x["total_spent"], reverse=True)
# Limit results
customers = customers[:limit]
return {
"period": period,
"customers": customers,
"total_sales": total_sales
}
@staticmethod
def get_dashboard_summary(db: Session) -> dict[str, Any]:
"""
Get a summary of key metrics for the dashboard.
Args:
db: Database session
Returns:
Dictionary with dashboard summary data
"""
# Get sales summary for last 30 days
sales_summary = AdminDashboardService.get_sales_summary(db, TimePeriod.LAST_30_DAYS)
# Count pending orders
pending_orders = db.query(Order).filter(Order.status == OrderStatus.PENDING).count()
# Count low stock products (less than 5 items)
low_stock_products = db.query(Product).filter(
Product.stock_quantity <= 5,
Product.status != ProductStatus.DISCONTINUED
).count()
# Count new customers in the last 30 days
last_30_days = datetime.now() - timedelta(days=30)
new_customers = db.query(User).filter(
User.created_at >= last_30_days,
User.role == UserRole.CUSTOMER
).count()
# Count total products and customers
total_products = db.query(Product).count()
total_customers = db.query(User).filter(User.role == UserRole.CUSTOMER).count()
return {
"sales_summary": sales_summary,
"pending_orders": pending_orders,
"low_stock_products": low_stock_products,
"new_customers": new_customers,
"total_products": total_products,
"total_customers": total_customers
}
@staticmethod
def get_orders_by_status(db: Session) -> list[dict[str, Any]]:
"""
Get order counts by status.
Args:
db: Database session
Returns:
List of dictionaries with order status counts
"""
# Count orders by status
status_counts = db.query(
Order.status,
func.count(Order.id).label('count')
).group_by(Order.status).all()
# Calculate total orders
total_orders = sum(count for _, count in status_counts)
# Format results
result = []
for status, count in status_counts:
percentage = (count / total_orders * 100) if total_orders > 0 else 0
result.append({
"status": status.value,
"count": count,
"percentage": percentage
})
# Sort by count (descending)
result.sort(key=lambda x: x["count"], reverse=True)
return result

136
app/services/inventory.py Normal file
View File

@ -0,0 +1,136 @@
import logging
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.models.product import Product, ProductStatus
logger = logging.getLogger(__name__)
class InventoryService:
"""
Service for managing product inventory.
"""
@staticmethod
def update_stock(
db: Session,
product_id: str,
quantity_change: int,
operation: str = "add"
) -> Product:
"""
Update the stock quantity of a product.
Args:
db: Database session
product_id: Product ID
quantity_change: Amount to change stock by (positive or negative)
operation: "add" to increase stock, "subtract" to decrease
Returns:
Updated product object
"""
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Product not found"
)
if operation == "add":
product.stock_quantity += quantity_change
# If stock was 0 and now it's not, update status
if product.stock_quantity > 0 and product.status == ProductStatus.OUT_OF_STOCK:
product.status = ProductStatus.PUBLISHED
elif operation == "subtract":
if product.stock_quantity < quantity_change:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot subtract {quantity_change} units. Only {product.stock_quantity} in stock."
)
product.stock_quantity -= quantity_change
# If stock is now 0, update status
if product.stock_quantity == 0 and product.status == ProductStatus.PUBLISHED:
product.status = ProductStatus.OUT_OF_STOCK
else:
raise ValueError(f"Invalid operation: {operation}. Must be 'add' or 'subtract'.")
db.commit()
db.refresh(product)
logger.info(f"Updated stock for product {product.id}. New quantity: {product.stock_quantity}")
return product
@staticmethod
def get_low_stock_products(
db: Session,
threshold: int = 5,
category_id: str | None = None,
seller_id: str | None = None
) -> list[Product]:
"""
Get products with low stock.
Args:
db: Database session
threshold: Stock threshold to consider "low"
category_id: Optional category ID to filter by
seller_id: Optional seller ID to filter by
Returns:
List of products with low stock
"""
query = db.query(Product).filter(Product.stock_quantity <= threshold)
if category_id:
query = query.filter(Product.category_id == category_id)
if seller_id:
query = query.filter(Product.seller_id == seller_id)
return query.all()
@staticmethod
def bulk_update_stock(
db: Session,
updates: list[dict[str, Any]]
) -> list[Product]:
"""
Update stock for multiple products at once.
Args:
db: Database session
updates: List of dicts with product_id, quantity_change, and operation
Returns:
List of updated products
"""
updated_products = []
for update in updates:
product_id = update.get('product_id')
quantity_change = update.get('quantity_change', 0)
operation = update.get('operation', 'add')
if not product_id or not isinstance(quantity_change, int):
logger.warning(f"Skipping invalid update: {update}")
continue
try:
product = InventoryService.update_stock(
db, product_id, quantity_change, operation
)
updated_products.append(product)
except Exception as e:
logger.error(f"Error updating stock for product {product_id}: {str(e)}")
return updated_products

188
app/services/payment.py Normal file
View File

@ -0,0 +1,188 @@
import json
import logging
import uuid
from typing import Any
from app.models.order import Order, OrderStatus
from app.models.payment import Payment, PaymentMethod, PaymentStatus
logger = logging.getLogger(__name__)
class PaymentService:
"""
Payment service for processing payments.
This is a mock implementation that simulates payment processing for demonstration purposes.
In a real-world application, this would integrate with actual payment providers.
"""
@staticmethod
async def process_payment(
db_session,
order: Order,
payment_method: PaymentMethod,
payment_details: dict[str, Any]
) -> dict[str, Any]:
"""
Process a payment for an order.
Args:
db_session: Database session
order: Order to be paid
payment_method: Payment method to use
payment_details: Additional payment details
Returns:
Dictionary with payment result details
"""
# Mock payment processing
# In a real application, this would call the payment provider's API
# Create a mock transaction ID
transaction_id = f"TRANS-{uuid.uuid4()}"
# Simulate payment processing
success = True
error_message = None
status = PaymentStatus.COMPLETED
# For demonstration purposes, let's fail some payments randomly based on order ID
if int(order.id.replace("-", ""), 16) % 10 == 0:
success = False
error_message = "Payment declined by the provider"
status = PaymentStatus.FAILED
# Create payment record
payment = Payment(
id=str(uuid.uuid4()),
order_id=order.id,
amount=order.total_amount,
payment_method=payment_method,
status=status,
transaction_id=transaction_id if success else None,
payment_details=json.dumps(payment_details),
error_message=error_message,
)
db_session.add(payment)
# Update order status based on payment result
if success:
order.status = OrderStatus.PROCESSING
db_session.commit()
# Return payment result
return {
"success": success,
"payment_id": payment.id,
"status": status,
"transaction_id": transaction_id if success else None,
"error_message": error_message,
"redirect_url": None, # In real payment flows, this might be a URL to redirect the user to
}
@staticmethod
async def process_stripe_payment(
db_session,
order: Order,
payment_details: dict[str, Any]
) -> dict[str, Any]:
"""
Process a payment using Stripe.
Args:
db_session: Database session
order: Order to be paid
payment_details: Stripe payment details including token
Returns:
Dictionary with payment result details
"""
logger.info(f"Processing Stripe payment for order {order.id}")
# In a real application, this would use the Stripe API
# Example code (not actually executed):
# import stripe
# stripe.api_key = settings.STRIPE_API_KEY
# try:
# charge = stripe.Charge.create(
# amount=int(order.total_amount * 100), # Amount in cents
# currency="usd",
# source=payment_details.get("token"),
# description=f"Payment for order {order.order_number}",
# metadata={"order_id": order.id}
# )
# transaction_id = charge.id
# success = True
# error_message = None
# except stripe.error.CardError as e:
# transaction_id = None
# success = False
# error_message = e.error.message
# Mock implementation
return await PaymentService.process_payment(
db_session, order, PaymentMethod.STRIPE, payment_details
)
@staticmethod
async def process_paypal_payment(
db_session,
order: Order,
payment_details: dict[str, Any]
) -> dict[str, Any]:
"""
Process a payment using PayPal.
Args:
db_session: Database session
order: Order to be paid
payment_details: PayPal payment details
Returns:
Dictionary with payment result details and potentially a redirect URL
"""
logger.info(f"Processing PayPal payment for order {order.id}")
# In a real application, this would use the PayPal API
# Mock implementation
result = await PaymentService.process_payment(
db_session, order, PaymentMethod.PAYPAL, payment_details
)
# PayPal often requires redirect to complete payment
if result["success"]:
result["redirect_url"] = f"https://www.paypal.com/checkout/mock-redirect?order_id={order.id}"
return result
@staticmethod
async def verify_payment(
db_session,
payment_id: str
) -> Payment | None:
"""
Verify the status of a payment.
Args:
db_session: Database session
payment_id: ID of the payment to verify
Returns:
Updated payment object or None if not found
"""
# Get the payment
payment = db_session.query(Payment).filter(Payment.id == payment_id).first()
if not payment:
return None
# In a real application, this would check with the payment provider's API
# to get the current status of the payment
# For mock implementation, we'll just return the payment as is
return payment

194
app/services/search.py Normal file
View File

@ -0,0 +1,194 @@
from typing import Any
from sqlalchemy import and_, asc, desc, func, or_
from sqlalchemy.orm import Session
from app.models.category import Category
from app.models.product import Product, ProductStatus
from app.models.review import Review
from app.models.tag import ProductTag
class SearchService:
"""
Service for advanced search and filtering of products.
"""
@staticmethod
def search_products(
db: Session,
search_query: str | None = None,
category_id: str | None = None,
tag_ids: list[str] | None = None,
min_price: float | None = None,
max_price: float | None = None,
min_rating: int | None = None,
status: ProductStatus | None = ProductStatus.PUBLISHED,
sort_by: str = "relevance",
sort_order: str = "desc",
is_featured: bool | None = None,
seller_id: str | None = None,
offset: int = 0,
limit: int = 100
) -> dict[str, Any]:
"""
Search and filter products with advanced options.
Args:
db: Database session
search_query: Text to search in product name and description
category_id: Filter by category ID
tag_ids: Filter by tag IDs (products must have ALL specified tags)
min_price: Minimum price filter
max_price: Maximum price filter
min_rating: Minimum average rating filter
status: Filter by product status
sort_by: Field to sort by (name, price, created_at, rating, relevance)
sort_order: Sort order (asc or desc)
is_featured: Filter by featured status
seller_id: Filter by seller ID
offset: Pagination offset
limit: Pagination limit
Returns:
Dict with products and total count
"""
# Start with base query
query = db.query(Product).outerjoin(Review, Product.id == Review.product_id)
# Apply filters
filters = []
# Status filter
if status:
filters.append(Product.status == status)
# Search query filter
if search_query:
search_term = f"%{search_query}%"
filters.append(
or_(
Product.name.ilike(search_term),
Product.description.ilike(search_term),
Product.sku.ilike(search_term)
)
)
# Category filter
if category_id:
# Get all subcategory IDs recursively
subcategory_ids = [category_id]
def get_subcategories(parent_id):
subcategories = db.query(Category).filter(Category.parent_id == parent_id).all()
for subcategory in subcategories:
subcategory_ids.append(subcategory.id)
get_subcategories(subcategory.id)
get_subcategories(category_id)
filters.append(Product.category_id.in_(subcategory_ids))
# Tag filters
if tag_ids:
# This creates a subquery where products must have ALL the specified tags
for tag_id in tag_ids:
# For each tag, we add a separate exists condition to ensure all tags are present
tag_subquery = db.query(ProductTag).filter(
ProductTag.product_id == Product.id,
ProductTag.tag_id == tag_id
).exists()
filters.append(tag_subquery)
# Price filters
if min_price is not None:
filters.append(Product.price >= min_price)
if max_price is not None:
filters.append(Product.price <= max_price)
# Featured filter
if is_featured is not None:
filters.append(Product.is_featured == is_featured)
# Seller filter
if seller_id:
filters.append(Product.seller_id == seller_id)
# Apply all filters
if filters:
query = query.filter(and_(*filters))
# Rating filter (applied separately as it's an aggregation)
if min_rating is not None:
# Group by product ID and filter by average rating
query = query.group_by(Product.id).having(
func.avg(Review.rating) >= min_rating
)
# Count total results before pagination
total_count = query.count()
# Apply sorting
if sort_by == "name":
order_func = asc if sort_order == "asc" else desc
query = query.order_by(order_func(Product.name))
elif sort_by == "price":
order_func = asc if sort_order == "asc" else desc
query = query.order_by(order_func(Product.price))
elif sort_by == "created_at":
order_func = asc if sort_order == "asc" else desc
query = query.order_by(order_func(Product.created_at))
elif sort_by == "rating":
order_func = asc if sort_order == "asc" else desc
query = query.group_by(Product.id).order_by(order_func(func.avg(Review.rating)))
else: # Default to relevance (works best with search_query)
# For relevance, if there's a search query, we first sort by search match quality
if search_query:
# This prioritizes exact name matches, then description matches
search_term = f"%{search_query}%"
# Custom ordering function for relevance
relevance_score = (
# Exact match in name (highest priority)
func.case(
[(Product.name.ilike(search_query), 100)],
else_=0
) +
# Name starts with the query
func.case(
[(Product.name.ilike(f"{search_query}%"), 50)],
else_=0
) +
# Name contains the query
func.case(
[(Product.name.ilike(search_term), 25)],
else_=0
) +
# Description contains the query
func.case(
[(Product.description.ilike(search_term), 10)],
else_=0
)
)
order_func = desc if sort_order == "desc" else asc
query = query.order_by(order_func(relevance_score), desc(Product.is_featured))
else:
# If no search query, sort by featured status and most recent
query = query.order_by(desc(Product.is_featured), desc(Product.created_at))
# Apply pagination
query = query.offset(offset).limit(limit)
# Execute query
products = query.all()
# Prepare response data
return {
"total": total_count,
"products": products,
"offset": offset,
"limit": limit,
}

1
app/utils/__init__.py Normal file
View File

@ -0,0 +1 @@
# Utility functions and helpers

60
app/utils/db_init.py Normal file
View File

@ -0,0 +1,60 @@
import logging
import sys
from pathlib import Path
# Add the project root to the Python path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from sqlalchemy import create_engine
from app.core.config import settings
from app.core.database import SessionLocal
from app.core.security import get_password_hash
from app.models.user import User, UserRole
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def init_db():
"""Initialize the database with required tables and initial admin user."""
try:
# Create database directory if it doesn't exist
settings.DB_DIR.mkdir(parents=True, exist_ok=True)
# Create database engine
engine = create_engine(settings.SQLALCHEMY_DATABASE_URL)
# Create an admin user
db = SessionLocal()
# Check if admin user already exists
existing_admin = db.query(User).filter(User.email == settings.FIRST_SUPERUSER_EMAIL).first()
if not existing_admin:
logger.info("Creating initial admin user...")
admin_user = User(
email=settings.FIRST_SUPERUSER_EMAIL,
hashed_password=get_password_hash(settings.FIRST_SUPERUSER_PASSWORD),
is_active=True,
role=UserRole.ADMIN,
first_name="Admin",
last_name="User",
email_verified=True
)
db.add(admin_user)
db.commit()
logger.info(f"Admin user created with email: {settings.FIRST_SUPERUSER_EMAIL}")
else:
logger.info("Admin user already exists")
db.close()
logger.info("Database initialization completed successfully")
except Exception as e:
logger.error(f"Error initializing database: {e}")
raise
if __name__ == "__main__":
logger.info("Creating initial database tables and admin user")
init_db()
logger.info("Initial data created")

17
docker-compose.yml Normal file
View File

@ -0,0 +1,17 @@
version: '3.8'
services:
api:
build: .
container_name: ecommerce_api
ports:
- "8000:8000"
volumes:
- ./storage:/app/storage
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s

193
main.py Normal file
View File

@ -0,0 +1,193 @@
import logging
import sys
import time
from logging.handlers import RotatingFileHandler
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.core.config import settings
from app.middlewares.rate_limiter import RateLimitMiddleware
from app.middlewares.security import SecurityHeadersMiddleware
# Import routers
from app.routers import (
admin,
auth,
cart,
categories,
health,
inventory,
orders,
payments,
products,
reviews,
search,
tags,
users,
)
# Configure logging
log_dir = Path("/app/storage/logs")
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / "app.log"
# Set up rotating file handler
file_handler = RotatingFileHandler(log_file, maxBytes=10485760, backupCount=5)
file_handler.setFormatter(logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
))
# Set up console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
))
# Configure root logger
logging.basicConfig(
level=logging.INFO,
handlers=[file_handler, console_handler]
)
# Create FastAPI app instance
app = FastAPI(
title="Comprehensive E-Commerce API",
description="""
# E-Commerce API
A full-featured e-commerce API built with FastAPI and SQLite.
## Features
* **User Management**: Registration, authentication, profiles
* **Product Management**: CRUD operations, image uploads
* **Category & Tag Management**: Hierarchical categories, product tagging
* **Shopping Cart**: Add, update, remove items
* **Order Processing**: Create orders, track status
* **Payment Integration**: Process payments with multiple methods
* **Inventory Management**: Track stock levels
* **Search & Filtering**: Advanced product search and filtering
* **Reviews & Ratings**: User reviews and product ratings
* **Admin Dashboard**: Sales analytics and reporting
## Authentication
Most endpoints require authentication using JSON Web Tokens (JWT).
To authenticate:
1. Register a user or use the default admin credentials
2. Login using the /api/auth/login endpoint
3. Use the returned access token in the Authorization header for subsequent requests:
`Authorization: Bearer {your_access_token}`
## Getting Started
1. Create a user account using the /api/auth/register endpoint
2. Browse products using the /api/products endpoints
3. Add items to your cart using the /api/cart endpoints
4. Create an order using the /api/orders endpoints
5. Process payment using the /api/payments endpoints
## API Status Codes
* 200: Success
* 201: Created
* 204: No Content
* 400: Bad Request
* 401: Unauthorized
* 403: Forbidden
* 404: Not Found
* 422: Validation Error
* 429: Too Many Requests
* 500: Internal Server Error
""",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
openapi_tags=[
{"name": "Health", "description": "Health check endpoint"},
{"name": "Authentication", "description": "User registration and authentication"},
{"name": "Users", "description": "User profile management"},
{"name": "Products", "description": "Product management and information"},
{"name": "Categories", "description": "Product category management"},
{"name": "Tags", "description": "Product tag management"},
{"name": "Shopping Cart", "description": "Shopping cart operations"},
{"name": "Orders", "description": "Order creation and management"},
{"name": "Payments", "description": "Payment processing and management"},
{"name": "Reviews", "description": "Product reviews and ratings"},
{"name": "Search", "description": "Product search and filtering"},
{"name": "Inventory", "description": "Inventory management"},
{"name": "Admin", "description": "Admin dashboard and analytics"},
],
license_info={
"name": "MIT",
"url": "https://opensource.org/licenses/MIT",
},
contact={
"name": "E-Commerce API Support",
"email": "support@example.com",
"url": "https://example.com/support",
},
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, replace this with specific origins
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Add security headers middleware
app.add_middleware(
SecurityHeadersMiddleware,
content_security_policy="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'"
)
# Add rate limiting middleware
app.add_middleware(
RateLimitMiddleware,
rate_limit_per_minute=settings.RATE_LIMIT_PER_MINUTE,
whitelist_paths=["/health", "/docs", "/redoc", "/openapi.json"]
)
# Add request timing middleware
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
start_time = time.time()
response = await call_next(request)
process_time = time.time() - start_time
response.headers["X-Process-Time"] = f"{process_time:.4f} sec"
return response
# Add global exception handler
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logging.error(f"Unhandled exception: {str(exc)}")
return JSONResponse(
status_code=500,
content={"detail": "An unexpected error occurred. Please try again later."}
)
# Include routers
app.include_router(health.router, tags=["Health"])
app.include_router(users.router, prefix="/api/users", tags=["Users"])
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
app.include_router(products.router, prefix="/api/products", tags=["Products"])
app.include_router(categories.router, prefix="/api/categories", tags=["Categories"])
app.include_router(tags.router, prefix="/api/tags", tags=["Tags"])
app.include_router(cart.router, prefix="/api/cart", tags=["Shopping Cart"])
app.include_router(orders.router, prefix="/api/orders", tags=["Orders"])
app.include_router(payments.router, prefix="/api/payments", tags=["Payments"])
app.include_router(reviews.router, prefix="/api/reviews", tags=["Reviews"])
app.include_router(search.router, prefix="/api/search", tags=["Search"])
app.include_router(inventory.router, prefix="/api/inventory", tags=["Inventory"])
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

82
migrations/env.py Normal file
View File

@ -0,0 +1,82 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.database import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""
Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True, # For SQLite
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""
Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
is_sqlite = connection.dialect.name == "sqlite"
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=is_sqlite, # Enable batch mode for SQLite
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

26
migrations/script.py.mako Normal file
View File

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,231 @@
"""
Initial database setup
Revision ID: 001
Revises:
Create Date: 2023-07-25 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import sqlite
# revision identifiers, used by Alembic.
revision: str = '001'
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Create enum types for SQLite
user_role_type = sa.Enum('customer', 'seller', 'admin', name='userroletype')
product_status_type = sa.Enum('draft', 'published', 'out_of_stock', 'discontinued', name='productstatustype')
order_status_type = sa.Enum('pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded', name='orderstatustype')
shipping_method_type = sa.Enum('standard', 'express', 'overnight', 'pickup', 'digital', name='shippingmethodtype')
payment_status_type = sa.Enum('pending', 'processing', 'completed', 'failed', 'refunded', name='paymentstatustype')
payment_method_type = sa.Enum('credit_card', 'paypal', 'bank_transfer', 'cash_on_delivery', 'stripe', 'apple_pay', 'google_pay', name='paymentmethodtype')
# Users table
op.create_table(
'users',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('email', sa.String(255), nullable=False, unique=True, index=True),
sa.Column('hashed_password', sa.String(255), nullable=False),
sa.Column('first_name', sa.String(100), nullable=True),
sa.Column('last_name', sa.String(100), nullable=True),
sa.Column('is_active', sa.Boolean(), default=True),
sa.Column('role', user_role_type, default='customer'),
sa.Column('phone_number', sa.String(20), nullable=True),
sa.Column('profile_image', sa.String(255), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now()),
sa.Column('address_line1', sa.String(255), nullable=True),
sa.Column('address_line2', sa.String(255), nullable=True),
sa.Column('city', sa.String(100), nullable=True),
sa.Column('state', sa.String(100), nullable=True),
sa.Column('postal_code', sa.String(20), nullable=True),
sa.Column('country', sa.String(100), nullable=True),
sa.Column('email_verified', sa.Boolean(), default=False),
sa.Column('verification_token', sa.String(255), nullable=True),
sa.Column('reset_password_token', sa.String(255), nullable=True),
sa.Column('reset_token_expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('bio', sa.Text(), nullable=True),
)
# Categories table
op.create_table(
'categories',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('name', sa.String(100), nullable=False, index=True),
sa.Column('slug', sa.String(120), nullable=False, unique=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('image', sa.String(255), nullable=True),
sa.Column('parent_id', sa.String(36), sa.ForeignKey('categories.id'), nullable=True),
sa.Column('is_active', sa.Boolean(), default=True),
sa.Column('display_order', sa.Integer(), default=0),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now()),
)
# Tags table
op.create_table(
'tags',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('name', sa.String(50), nullable=False, unique=True, index=True),
sa.Column('slug', sa.String(60), nullable=False, unique=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now()),
)
# Products table
op.create_table(
'products',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('name', sa.String(255), nullable=False, index=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('price', sa.Float(), nullable=False),
sa.Column('sku', sa.String(100), unique=True, nullable=True),
sa.Column('barcode', sa.String(100), unique=True, nullable=True),
sa.Column('stock_quantity', sa.Integer(), default=0),
sa.Column('weight', sa.Float(), nullable=True),
sa.Column('dimensions', sa.String(100), nullable=True),
sa.Column('status', product_status_type, default='draft'),
sa.Column('is_featured', sa.Boolean(), default=False),
sa.Column('is_digital', sa.Boolean(), default=False),
sa.Column('digital_download_link', sa.String(512), nullable=True),
sa.Column('slug', sa.String(255), nullable=False, unique=True),
sa.Column('tax_rate', sa.Float(), default=0.0),
sa.Column('discount_price', sa.Float(), nullable=True),
sa.Column('discount_start_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('discount_end_date', sa.DateTime(timezone=True), nullable=True),
sa.Column('category_id', sa.String(36), sa.ForeignKey('categories.id'), nullable=True),
sa.Column('seller_id', sa.String(36), sa.ForeignKey('users.id'), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now()),
)
# Product Images table
op.create_table(
'product_images',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('product_id', sa.String(36), sa.ForeignKey('products.id', ondelete='CASCADE'), nullable=False),
sa.Column('image_url', sa.String(512), nullable=False),
sa.Column('alt_text', sa.String(255), nullable=True),
sa.Column('is_primary', sa.Boolean(), default=False),
sa.Column('display_order', sa.Integer(), default=0),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# Product-Tag association table
op.create_table(
'product_tags',
sa.Column('product_id', sa.String(36), sa.ForeignKey('products.id', ondelete='CASCADE'), primary_key=True),
sa.Column('tag_id', sa.String(36), sa.ForeignKey('tags.id', ondelete='CASCADE'), primary_key=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# Cart Items table
op.create_table(
'cart_items',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('product_id', sa.String(36), sa.ForeignKey('products.id', ondelete='CASCADE'), nullable=False),
sa.Column('quantity', sa.Integer(), default=1, nullable=False),
sa.Column('price_at_addition', sa.Float(), nullable=False),
sa.Column('custom_properties', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now()),
)
# Orders table
op.create_table(
'orders',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id'), nullable=False),
sa.Column('order_number', sa.String(50), nullable=False, unique=True, index=True),
sa.Column('status', order_status_type, default='pending'),
sa.Column('total_amount', sa.Float(), nullable=False),
sa.Column('subtotal', sa.Float(), nullable=False),
sa.Column('tax_amount', sa.Float(), nullable=False),
sa.Column('shipping_amount', sa.Float(), nullable=False),
sa.Column('discount_amount', sa.Float(), default=0.0),
sa.Column('shipping_method', shipping_method_type, nullable=True),
sa.Column('tracking_number', sa.String(100), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('shipping_address', sqlite.JSON(), nullable=True),
sa.Column('billing_address', sqlite.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now()),
)
# Order Items table
op.create_table(
'order_items',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('order_id', sa.String(36), sa.ForeignKey('orders.id', ondelete='CASCADE'), nullable=False),
sa.Column('product_id', sa.String(36), sa.ForeignKey('products.id'), nullable=False),
sa.Column('quantity', sa.Integer(), default=1, nullable=False),
sa.Column('unit_price', sa.Float(), nullable=False),
sa.Column('subtotal', sa.Float(), nullable=False),
sa.Column('discount', sa.Float(), default=0.0),
sa.Column('tax_amount', sa.Float(), default=0.0),
sa.Column('product_name', sa.String(255), nullable=False),
sa.Column('product_sku', sa.String(100), nullable=True),
sa.Column('product_options', sqlite.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# Payments table
op.create_table(
'payments',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('order_id', sa.String(36), sa.ForeignKey('orders.id'), nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('payment_method', payment_method_type, nullable=False),
sa.Column('status', payment_status_type, default='pending'),
sa.Column('transaction_id', sa.String(255), nullable=True, unique=True),
sa.Column('payment_details', sqlite.JSON(), nullable=True),
sa.Column('error_message', sa.String(512), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now()),
)
# Reviews table
op.create_table(
'reviews',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('product_id', sa.String(36), sa.ForeignKey('products.id', ondelete='CASCADE'), nullable=False),
sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id'), nullable=False),
sa.Column('rating', sa.Integer(), nullable=False),
sa.Column('title', sa.String(255), nullable=True),
sa.Column('comment', sa.Text(), nullable=True),
sa.Column('is_verified_purchase', sa.Boolean(), default=False),
sa.Column('is_approved', sa.Boolean(), default=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), onupdate=sa.func.now()),
)
# Create indexes
op.create_index('ix_users_email', 'users', ['email'], unique=True)
op.create_index('ix_categories_name', 'categories', ['name'])
op.create_index('ix_products_name', 'products', ['name'])
op.create_index('ix_tags_name', 'tags', ['name'], unique=True)
op.create_index('ix_orders_order_number', 'orders', ['order_number'], unique=True)
def downgrade() -> None:
# Drop tables in reverse order of creation
op.drop_table('reviews')
op.drop_table('payments')
op.drop_table('order_items')
op.drop_table('orders')
op.drop_table('cart_items')
op.drop_table('product_tags')
op.drop_table('product_images')
op.drop_table('products')
op.drop_table('tags')
op.drop_table('categories')
op.drop_table('users')

53
pyproject.toml Normal file
View File

@ -0,0 +1,53 @@
[tool.ruff]
# Enable Pyflakes (`F`), McCabe complexity (`C90`), pycodestyle (`E`),
# isort (`I`), PEP8 naming (`N`), and more
line-length = 100
target-version = "py310"
# Exclude a variety of commonly ignored directories
exclude = [
".git",
".ruff_cache",
"__pycache__",
"venv",
".env",
".venv",
"env",
"dist",
"build",
]
# Format code with black formatting style
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "auto"
docstring-code-format = true
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "D", "UP", "C90", "B", "C4", "SIM", "RET"]
# Ignore specific rules
ignore = [
"E203", # Whitespace before ':' (conflicts with black)
"E501", # Line too long (handled by formatter)
"D100", # Missing module docstring (for now)
"D101", # Missing class docstring (for now)
"D103", # Missing function docstring (for now)
"D104", # Missing package docstring (for now)
"D203", # One blank line required before class docstring
"D212", # Multi-line docstring summary should start at the first line
]
# Allow autofix for all enabled rules (when `--fix`) is provided
fixable = ["ALL"]
unfixable = []
[tool.ruff.lint.isort]
known-first-party = ["app"]
# Per-file-ignores to clean code based on functionality
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["D104"]
"migrations/*.py" = ["D100", "D103", "D400", "D415"]
"app/models/*.py" = ["D101"]

20
requirements.txt Normal file
View File

@ -0,0 +1,20 @@
fastapi>=0.100.0
uvicorn>=0.22.0
sqlalchemy>=2.0.0
alembic>=1.11.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
pydantic[email]>=2.0.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
email-validator>=2.0.0
httpx>=0.24.0
pytest>=7.3.1
ruff>=0.0.272
python-dotenv>=1.0.0
Pillow>=10.0.0
redis>=4.6.0
tenacity>=8.2.2
ulid-py>=1.1.0
aiosqlite>=0.19.0

25
scripts/lint.sh Executable file
View File

@ -0,0 +1,25 @@
#!/bin/bash
# Run Ruff linter in check mode
echo "Running Ruff linter..."
ruff check .
# Check if linting failed
if [ $? -ne 0 ]; then
echo "Linting failed. Attempting to fix automatically..."
ruff check --fix .
# Check if auto-fixing resolved all issues
if [ $? -ne 0 ]; then
echo "Some issues could not be fixed automatically. Please fix them manually."
exit 1
else
echo "Auto-fixing succeeded!"
fi
fi
# Run Ruff formatter
echo "Running Ruff formatter..."
ruff format .
echo "Linting and formatting completed successfully!"