Create comprehensive e-commerce platform with FastAPI and SQLite
This commit is contained in:
parent
7c63d7380c
commit
aaa32ca932
69
.gitignore
vendored
Normal file
69
.gitignore
vendored
Normal 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
39
Dockerfile
Normal 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
239
README.md
@ -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
109
alembic.ini
Normal 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
1
app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# E-Commerce application package
|
1
app/core/__init__.py
Normal file
1
app/core/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Core functionalities for the application
|
65
app/core/config.py
Normal file
65
app/core/config.py
Normal 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
32
app/core/database.py
Normal 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
34
app/core/security.py
Normal 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
|
1
app/dependencies/__init__.py
Normal file
1
app/dependencies/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# FastAPI dependency functions
|
95
app/dependencies/auth.py
Normal file
95
app/dependencies/auth.py
Normal 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
|
1
app/middlewares/__init__.py
Normal file
1
app/middlewares/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Custom middleware components
|
155
app/middlewares/rate_limiter.py
Normal file
155
app/middlewares/rate_limiter.py
Normal 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"
|
47
app/middlewares/security.py
Normal file
47
app/middlewares/security.py
Normal 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
9
app/models/__init__.py
Normal 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
32
app/models/cart.py
Normal 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
29
app/models/category.py
Normal 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
75
app/models/order.py
Normal 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
45
app/models/payment.py
Normal 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
87
app/models/product.py
Normal 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
29
app/models/review.py
Normal 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
27
app/models/tag.py
Normal 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
55
app/models/user.py
Normal 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
1
app/routers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# API router modules
|
98
app/routers/admin.py
Normal file
98
app/routers/admin.py
Normal 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
118
app/routers/auth.py
Normal 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
293
app/routers/cart.py
Normal 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
313
app/routers/categories.py
Normal 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
27
app/routers/health.py
Normal 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
147
app/routers/inventory.py
Normal 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
323
app/routers/orders.py
Normal 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
244
app/routers/payments.py
Normal 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
441
app/routers/products.py
Normal 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
270
app/routers/reviews.py
Normal 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
68
app/routers/search.py
Normal 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
185
app/routers/tags.py
Normal 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
172
app/routers/users.py
Normal 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
1
app/schemas/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Pydantic schemas/models for request and response handling
|
79
app/schemas/admin.py
Normal file
79
app/schemas/admin.py
Normal 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
49
app/schemas/cart.py
Normal 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
40
app/schemas/category.py
Normal 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
107
app/schemas/order.py
Normal 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
57
app/schemas/payment.py
Normal 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
114
app/schemas/product.py
Normal 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
44
app/schemas/review.py
Normal 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
29
app/schemas/tag.py
Normal 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
88
app/schemas/user.py
Normal 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
1
app/services/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Service modules for business logic
|
437
app/services/admin.py
Normal file
437
app/services/admin.py
Normal 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
136
app/services/inventory.py
Normal 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
188
app/services/payment.py
Normal 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
194
app/services/search.py
Normal 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
1
app/utils/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Utility functions and helpers
|
60
app/utils/db_init.py
Normal file
60
app/utils/db_init.py
Normal 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
17
docker-compose.yml
Normal 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
193
main.py
Normal 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
82
migrations/env.py
Normal 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
26
migrations/script.py.mako
Normal 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"}
|
231
migrations/versions/001_initial_database_setup.py
Normal file
231
migrations/versions/001_initial_database_setup.py
Normal 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
53
pyproject.toml
Normal 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
20
requirements.txt
Normal 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
25
scripts/lint.sh
Executable 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!"
|
Loading…
x
Reference in New Issue
Block a user