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