Implement Deft Trade DeFi Trading Simulation Platform Backend

This commit is contained in:
Automated Action 2025-06-13 11:58:36 +00:00
parent 4a3479cf00
commit 8898d97d8a
65 changed files with 4811 additions and 2 deletions

213
README.md
View File

@ -1,3 +1,212 @@
# FastAPI Application
# Deft Trade - DeFi Trading Simulation Platform Backend
This is a FastAPI application bootstrapped by BackendIM, the AI-powered backend generation platform.
This is a secure, admin-controlled backend for a decentralized finance (DeFi) trading simulation platform called Deft Trade. The platform simulates trading bots based on admin-configured logic without actual blockchain integration.
## Features
- **Authentication System**: JWT-based authentication with optional 2FA and email verification
- **Wallet System**: Automatic creation of Spot and Trading wallets for users
- **Manual USDT Deposits**: Admin approval workflow for deposits
- **Manual Withdrawals**: Admin review and processing of withdrawals
- **Wallet Transfers**: Users can transfer between Spot and Trading wallets
- **Bot Marketplace**: Admin-controlled trading bots with configurable parameters
- **Bot Purchase & Simulation**: Simulated bot trading with automatic ROI distribution
- **KYC System**: Document upload and admin verification
- **Admin Dashboard**: Comprehensive admin control panel
## Technology Stack
- **Framework**: FastAPI (Python)
- **Database**: SQLite with SQLAlchemy ORM
- **Authentication**: JWT with optional TOTP-based 2FA
- **Migrations**: Alembic
- **File Storage**: Local file system
- **Email**: SMTP integration (optional)
## Setup and Installation
### Prerequisites
- Python 3.8+
- SQLite
### Installation
1. Clone the repository:
```
git clone <repository-url>
cd defitradingsimulationplatformbackend
```
2. Create a virtual environment:
```
python -m venv venv
source venv/bin/activate # On Windows, use venv\Scripts\activate
```
3. Install dependencies:
```
pip install -r requirements.txt
```
4. Create a `.env` file based on `.env.example`:
```
cp .env.example .env
```
Edit the `.env` file to set your configuration values, especially:
- `SECRET_KEY` and `JWT_SECRET_KEY` (use secure random strings)
- `ADMIN_EMAIL` and `ADMIN_PASSWORD` (for the default admin user)
- Email settings if you want to enable email notifications
5. Run the database migrations:
```
alembic upgrade head
```
6. Run the application:
```
uvicorn main:app --reload
```
7. Access the API documentation at: http://localhost:8000/docs
### Directory Structure
```
.
├── alembic.ini # Alembic configuration
├── migrations/ # Database migrations
├── app/ # Main application package
│ ├── api/ # API endpoints
│ │ └── v1/ # API version 1
│ │ └── endpoints/ # API endpoint implementations
│ ├── core/ # Core functionality
│ ├── crud/ # CRUD operations
│ ├── db/ # Database session and models
│ ├── models/ # SQLAlchemy models
│ ├── schemas/ # Pydantic schemas
│ ├── services/ # Business logic services
│ └── storage/ # File storage directories
├── main.py # Application entry point
└── requirements.txt # Project dependencies
```
## Environment Variables
Create a `.env` file in the root directory with the following variables:
| Variable | Description | Default Value |
|----------|-------------|---------------|
| PROJECT_NAME | Application name | "Deft Trade" |
| DEBUG | Debug mode | True |
| SECRET_KEY | Secret key for general app encryption | Auto-generated |
| JWT_SECRET_KEY | Secret key for JWT tokens | Auto-generated |
| ACCESS_TOKEN_EXPIRE_MINUTES | JWT access token expiration time | 30 |
| REFRESH_TOKEN_EXPIRE_DAYS | JWT refresh token expiration time | 7 |
| ALGORITHM | JWT algorithm | "HS256" |
| BACKEND_CORS_ORIGINS | CORS origins | ["*"] |
| EMAILS_ENABLED | Enable email sending | False |
| SMTP_TLS | Use TLS for SMTP | True |
| SMTP_PORT | SMTP port | 587 |
| SMTP_HOST | SMTP host | None |
| SMTP_USER | SMTP username | None |
| SMTP_PASSWORD | SMTP password | None |
| EMAILS_FROM_EMAIL | Sender email | None |
| EMAILS_FROM_NAME | Sender name | None |
| ADMIN_EMAIL | Default admin email | "admin@defttrade.com" |
| ADMIN_PASSWORD | Default admin password | "change-me-please" |
| TWO_FACTOR_REQUIRED | Require 2FA for all users | False |
| BOT_SIMULATION_INTERVAL | Bot simulation check interval (seconds) | 60 |
| MIN_DEPOSIT_AMOUNT | Minimum deposit amount | 10.0 |
| MIN_WITHDRAWAL_AMOUNT | Minimum withdrawal amount | 10.0 |
| WITHDRAWAL_FEE_PERCENTAGE | Withdrawal fee percentage | 1.0 |
| MAX_UPLOAD_SIZE | Maximum upload size in bytes | 5242880 (5MB) |
## API Endpoints
### Authentication
- POST `/api/v1/auth/register` - Register new user
- POST `/api/v1/auth/login` - User login
- POST `/api/v1/auth/refresh-token` - Refresh JWT token
- POST `/api/v1/auth/request-password-reset` - Request password reset
- POST `/api/v1/auth/reset-password` - Reset password
- POST `/api/v1/auth/enable-2fa` - Enable 2FA
- POST `/api/v1/auth/verify-2fa` - Verify 2FA token
- GET `/api/v1/auth/me` - Get current user info
### Wallets
- GET `/api/v1/wallets` - Get user wallets
- POST `/api/v1/wallets/transfer` - Transfer between wallets
### Deposits
- POST `/api/v1/deposits/request` - Create deposit request
- GET `/api/v1/deposits` - Get user deposits
- GET `/api/v1/admin/deposits/pending` - Get all pending deposits (admin)
- PUT `/api/v1/admin/{deposit_id}/approve` - Approve deposit (admin)
- PUT `/api/v1/admin/{deposit_id}/reject` - Reject deposit (admin)
### Withdrawals
- POST `/api/v1/withdrawals/request` - Create withdrawal request
- GET `/api/v1/withdrawals` - Get user withdrawals
- GET `/api/v1/admin/withdrawals/pending` - Get all pending withdrawals (admin)
- PUT `/api/v1/admin/{withdrawal_id}/approve` - Approve withdrawal (admin)
- PUT `/api/v1/admin/{withdrawal_id}/reject` - Reject withdrawal (admin)
### Bots
- GET `/api/v1/bots` - Get available bots
- POST `/api/v1/bots/{id}/purchase` - Purchase bot
- GET `/api/v1/bots/purchased` - Get purchased bots
- POST `/api/v1/admin/bots` - Create bot (admin)
- PUT `/api/v1/admin/bots/{id}` - Update bot (admin)
- DELETE `/api/v1/admin/bots/{id}` - Delete bot (admin)
### KYC
- POST `/api/v1/kyc/upload` - Upload KYC documents
- GET `/api/v1/kyc/status` - Get KYC status
- GET `/api/v1/admin/kyc/pending` - Get all pending KYC submissions (admin)
- PUT `/api/v1/admin/kyc/{id}/approve` - Approve KYC (admin)
- PUT `/api/v1/admin/kyc/{id}/reject` - Reject KYC (admin)
### Admin Dashboard
- GET `/api/v1/admin/users` - Get all users
- GET `/api/v1/admin/statistics` - Get platform statistics
- GET `/api/v1/admin/transactions` - Get all transactions
### Health Check
- GET `/health` - Application health check
## Development
### Running Tests
```
pytest
```
### Adding Migrations
If you need to modify the database schema:
1. Make changes to the SQLAlchemy models in `app/models/`
2. Create a new migration:
```
alembic revision --autogenerate -m "description of changes"
```
3. Apply the migration:
```
alembic upgrade head
```
### Running with Docker
Build and run the Docker image:
```
docker build -t deft-trade-backend .
docker run -p 8000:8000 deft-trade-backend
```
## License
This project is proprietary and confidential.

85
alembic.ini Normal file
View File

@ -0,0 +1,85 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
#truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat alembic/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# Use the absolute path to the SQLite database
sqlalchemy.url = sqlite:////app/storage/db/db.sqlite
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks=black
# black.type=console_scripts
# black.entrypoint=black
# black.options=-l 79
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

0
app/__init__.py Normal file
View File

0
app/api/__init__.py Normal file
View File

76
app/api/deps.py Normal file
View File

@ -0,0 +1,76 @@
from typing import Generator
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt
from pydantic import ValidationError
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.core.config import settings
from app.db.session import SessionLocal
reusable_oauth2 = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/login"
)
def get_db() -> Generator:
try:
db = SessionLocal()
yield db
finally:
db.close()
def get_current_user(
db: Session = Depends(get_db), token: str = Depends(reusable_oauth2)
) -> models.User:
try:
payload = jwt.decode(
token, settings.JWT_SECRET_KEY, algorithms=[settings.ALGORITHM]
)
token_data = schemas.TokenPayload(**payload)
except (jwt.JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user = crud.user.get(db, id=token_data.sub)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if not crud.user.is_active(user):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user",
)
return user
def get_current_active_verified_user(
current_user: models.User = Depends(get_current_user),
) -> models.User:
if not current_user.is_verified:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Email not verified",
)
return current_user
def get_current_admin(
current_user: models.User = Depends(get_current_user),
) -> models.User:
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
return current_user

0
app/api/v1/__init__.py Normal file
View File

13
app/api/v1/api.py Normal file
View File

@ -0,0 +1,13 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, wallets, deposits, withdrawals, bots, kyc, admin
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
api_router.include_router(wallets.router, prefix="/wallets", tags=["wallets"])
api_router.include_router(deposits.router, prefix="/deposits", tags=["deposits"])
api_router.include_router(withdrawals.router, prefix="/withdrawals", tags=["withdrawals"])
api_router.include_router(bots.router, prefix="/bots", tags=["bots"])
api_router.include_router(kyc.router, prefix="/kyc", tags=["kyc"])
api_router.include_router(admin.router, prefix="/admin", tags=["admin"])

View File

View File

@ -0,0 +1,286 @@
from typing import Any, List, Dict
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func
from app import crud, models, schemas
from app.api import deps
from app.services.bot_simulation import get_bot_simulation_stats
router = APIRouter()
@router.get("/users", response_model=List[schemas.User])
def read_users(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Retrieve all users (admin only).
"""
users = db.query(models.User).offset(skip).limit(limit).all()
return users
@router.get("/users/{user_id}", response_model=schemas.User)
def read_user(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
user_id: int,
) -> Any:
"""
Get a specific user by ID (admin only).
"""
user = crud.user.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return user
@router.put("/users/{user_id}/activate", response_model=schemas.User)
def activate_user(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
user_id: int,
) -> Any:
"""
Activate a user (admin only).
"""
user = crud.user.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if user.is_active:
return user
updated_user = crud.user.update(db, db_obj=user, obj_in={"is_active": True})
return updated_user
@router.put("/users/{user_id}/deactivate", response_model=schemas.User)
def deactivate_user(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
user_id: int,
) -> Any:
"""
Deactivate a user (admin only).
"""
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot deactivate your own account",
)
user = crud.user.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if not user.is_active:
return user
updated_user = crud.user.update(db, db_obj=user, obj_in={"is_active": False})
return updated_user
@router.get("/wallets/{user_id}", response_model=List[schemas.Wallet])
def read_user_wallets(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
user_id: int,
) -> Any:
"""
Get a specific user's wallets (admin only).
"""
user = crud.user.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
wallets = crud.wallet.get_by_user(db, user_id=user_id)
return wallets
@router.put("/wallets/{wallet_id}/adjust", response_model=schemas.Wallet)
def adjust_wallet_balance(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
wallet_id: int,
amount: float,
description: str,
) -> Any:
"""
Adjust a wallet's balance (admin only).
"""
wallet = crud.wallet.get(db, id=wallet_id)
if not wallet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Wallet not found",
)
# Check if adjustment would make balance negative
if wallet.balance + amount < 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Adjustment would result in negative balance",
)
# Update wallet balance
updated_wallet = crud.wallet.update_balance(
db, wallet_id=wallet_id, amount=abs(amount), add=(amount > 0)
)
# Create transaction record
crud.transaction.create(
db,
obj_in=schemas.TransactionCreate(
user_id=wallet.user_id,
wallet_id=wallet.id,
amount=amount,
transaction_type=models.TransactionType.ADMIN_ADJUSTMENT,
description=f"Admin adjustment: {description}",
),
)
return updated_wallet
@router.get("/transactions", response_model=List[schemas.Transaction])
def read_all_transactions(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Retrieve all transactions (admin only).
"""
transactions = crud.transaction.get_all(db, skip=skip, limit=limit)
return transactions
@router.get("/statistics", response_model=Dict)
def get_statistics(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
) -> Any:
"""
Get platform statistics (admin only).
"""
# User statistics
total_users = db.query(func.count(models.User.id)).scalar()
active_users = db.query(func.count(models.User.id)).filter(models.User.is_active).scalar()
verified_users = db.query(func.count(models.User.id)).filter(models.User.is_verified).scalar()
kyc_verified_users = db.query(func.count(models.User.id)).filter(models.User.is_kyc_verified).scalar()
# Wallet statistics
total_spot_balance = db.query(func.sum(models.Wallet.balance)).filter(
models.Wallet.wallet_type == models.WalletType.SPOT
).scalar() or 0
total_trading_balance = db.query(func.sum(models.Wallet.balance)).filter(
models.Wallet.wallet_type == models.WalletType.TRADING
).scalar() or 0
# Deposit statistics
total_deposits = db.query(func.count(models.Deposit.id)).scalar()
pending_deposits = db.query(func.count(models.Deposit.id)).filter(
models.Deposit.status == models.DepositStatus.PENDING
).scalar()
approved_deposits = db.query(func.count(models.Deposit.id)).filter(
models.Deposit.status == models.DepositStatus.APPROVED
).scalar()
total_deposit_amount = db.query(func.sum(models.Deposit.amount)).filter(
models.Deposit.status == models.DepositStatus.APPROVED
).scalar() or 0
# Withdrawal statistics
total_withdrawals = db.query(func.count(models.Withdrawal.id)).scalar()
pending_withdrawals = db.query(func.count(models.Withdrawal.id)).filter(
models.Withdrawal.status == models.WithdrawalStatus.PENDING
).scalar()
approved_withdrawals = db.query(func.count(models.Withdrawal.id)).filter(
models.Withdrawal.status == models.WithdrawalStatus.APPROVED
).scalar()
total_withdrawal_amount = db.query(func.sum(models.Withdrawal.amount)).filter(
models.Withdrawal.status == models.WithdrawalStatus.APPROVED
).scalar() or 0
# Bot statistics
total_bots = db.query(func.count(models.Bot.id)).scalar()
active_bots = db.query(func.count(models.Bot.id)).filter(models.Bot.is_active).scalar()
bot_stats = get_bot_simulation_stats(db)
# KYC statistics
total_kyc = db.query(func.count(models.KYC.id)).scalar()
pending_kyc = db.query(func.count(models.KYC.id)).filter(
models.KYC.status == models.KYCStatus.PENDING
).scalar()
approved_kyc = db.query(func.count(models.KYC.id)).filter(
models.KYC.status == models.KYCStatus.APPROVED
).scalar()
rejected_kyc = db.query(func.count(models.KYC.id)).filter(
models.KYC.status == models.KYCStatus.REJECTED
).scalar()
return {
"users": {
"total": total_users,
"active": active_users,
"verified": verified_users,
"kyc_verified": kyc_verified_users,
},
"wallets": {
"total_spot_balance": total_spot_balance,
"total_trading_balance": total_trading_balance,
"total_platform_balance": total_spot_balance + total_trading_balance,
},
"deposits": {
"total": total_deposits,
"pending": pending_deposits,
"approved": approved_deposits,
"total_amount": total_deposit_amount,
},
"withdrawals": {
"total": total_withdrawals,
"pending": pending_withdrawals,
"approved": approved_withdrawals,
"total_amount": total_withdrawal_amount,
},
"bots": {
"total": total_bots,
"active": active_bots,
**bot_stats,
},
"kyc": {
"total": total_kyc,
"pending": pending_kyc,
"approved": approved_kyc,
"rejected": rejected_kyc,
},
}

View File

@ -0,0 +1,384 @@
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from pydantic import ValidationError
from app import crud, models, schemas
from app.api import deps
from app.core import security
from app.core.config import settings
from app.core.email import send_email_verification, send_password_reset
from app.services.two_factor import generate_totp_secret, generate_qr_code, verify_totp
router = APIRouter()
@router.post("/register", response_model=schemas.User)
def register(
*,
db: Session = Depends(deps.get_db),
user_in: schemas.UserCreate,
) -> Any:
"""
Register a new user.
"""
user = crud.user.get_by_email(db, email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
user = crud.user.create(db, obj_in=user_in)
# Create wallets for the user
from app.models.wallet import WalletType
crud.wallet.create_for_user(db, user_id=user.id, wallet_type=WalletType.SPOT)
crud.wallet.create_for_user(db, user_id=user.id, wallet_type=WalletType.TRADING)
# Generate and send verification email
token = security.create_email_verification_token(user.email)
send_email_verification(user.email, token)
return user
@router.post("/login", response_model=schemas.Token)
def login(
db: Session = Depends(deps.get_db),
form_data: OAuth2PasswordRequestForm = Depends(),
) -> Any:
"""
Get an access token for future requests.
"""
user = crud.user.authenticate(
db, email=form_data.username, password=form_data.password
)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not crud.user.is_active(user):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user",
)
# If 2FA is enabled, return a special token requiring 2FA
if user.is_two_factor_enabled:
temp_token = security.create_access_token(
subject=str(user.id),
role=user.role,
expires_delta=timedelta(minutes=15)
)
return {
"access_token": temp_token,
"token_type": "bearer",
"requires_two_factor": True
}
# Regular login flow
access_token = security.create_access_token(
subject=str(user.id),
role=user.role,
)
refresh_token = security.create_refresh_token(
subject=str(user.id),
role=user.role,
)
return {
"access_token": access_token,
"token_type": "bearer",
"refresh_token": refresh_token,
"requires_two_factor": False
}
@router.post("/login/2fa", response_model=schemas.Token)
def login_two_factor(
*,
db: Session = Depends(deps.get_db),
two_factor_data: schemas.TwoFactorLogin,
) -> Any:
"""
Complete login with 2FA verification.
"""
try:
payload = security.jwt.decode(
two_factor_data.token,
settings.JWT_SECRET_KEY,
algorithms=[settings.ALGORITHM]
)
user_id = payload.get("sub")
except (security.jwt.JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
user = crud.user.get(db, id=user_id)
if not user or not user.is_two_factor_enabled:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or 2FA not enabled",
)
if not verify_totp(user.two_factor_secret, two_factor_data.code):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid 2FA code",
)
# Generate full access tokens after 2FA validation
access_token = security.create_access_token(
subject=str(user.id),
role=user.role,
)
refresh_token = security.create_refresh_token(
subject=str(user.id),
role=user.role,
)
return {
"access_token": access_token,
"token_type": "bearer",
"refresh_token": refresh_token,
"requires_two_factor": False
}
@router.post("/refresh-token", response_model=schemas.Token)
def refresh_token(
*,
db: Session = Depends(deps.get_db),
refresh_token_in: schemas.RefreshToken,
) -> Any:
"""
Refresh access token.
"""
try:
payload = security.jwt.decode(
refresh_token_in.refresh_token,
settings.JWT_SECRET_KEY,
algorithms=[settings.ALGORITHM]
)
user_id = payload.get("sub")
except (security.jwt.JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
user = crud.user.get(db, id=user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if not crud.user.is_active(user):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user",
)
access_token = security.create_access_token(
subject=str(user.id),
role=user.role,
)
return {
"access_token": access_token,
"token_type": "bearer",
"refresh_token": refresh_token_in.refresh_token,
"requires_two_factor": False
}
@router.post("/verify-email")
def verify_email(
*,
db: Session = Depends(deps.get_db),
email_verification: schemas.EmailVerification,
) -> Any:
"""
Verify user email.
"""
email = security.verify_token(email_verification.token, "email_verification")
if not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired token",
)
user = crud.user.get_by_email(db, email=email)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
if crud.user.is_verified(user):
return {"message": "Email already verified"}
crud.user.set_verified(db, user=user)
return {"message": "Email successfully verified"}
@router.post("/request-password-reset")
def request_password_reset(
*,
db: Session = Depends(deps.get_db),
password_reset: schemas.PasswordReset,
) -> Any:
"""
Request password reset.
"""
user = crud.user.get_by_email(db, email=password_reset.email)
if not user:
# Don't reveal that the user doesn't exist
return {"message": "If your email is registered, you will receive a password reset link"}
token = security.create_password_reset_token(user.email)
send_password_reset(user.email, token)
return {"message": "Password reset email sent"}
@router.post("/reset-password")
def reset_password(
*,
db: Session = Depends(deps.get_db),
password_reset: schemas.PasswordResetConfirm,
) -> Any:
"""
Reset password.
"""
email = security.verify_token(password_reset.token, "password_reset")
if not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired token",
)
user = crud.user.get_by_email(db, email=email)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
crud.user.update(db, db_obj=user, obj_in={"password": password_reset.new_password})
return {"message": "Password successfully reset"}
@router.post("/enable-2fa", response_model=dict)
def enable_two_factor(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_verified_user),
two_factor_setup: schemas.TwoFactorSetup,
) -> Any:
"""
Enable 2FA for the user.
"""
# Verify user's password before enabling 2FA
if not security.verify_password(two_factor_setup.password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect password",
)
# Generate 2FA secret
secret = generate_totp_secret()
# Generate QR code
qr_code = generate_qr_code(secret, current_user.email)
# Save the 2FA secret (not enabled yet until user verifies)
crud.user.set_user_two_factor_secret(db, user=current_user, secret=secret)
return {
"secret": secret,
"qr_code": qr_code,
"message": "2FA setup initialized. Verify with the code to enable 2FA."
}
@router.post("/verify-2fa")
def verify_two_factor(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_verified_user),
two_factor_verify: schemas.TwoFactorVerify,
) -> Any:
"""
Verify and enable 2FA.
"""
if not current_user.two_factor_secret:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="2FA not initialized",
)
if verify_totp(current_user.two_factor_secret, two_factor_verify.code):
crud.user.enable_two_factor(db, user=current_user)
return {"message": "2FA successfully enabled"}
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid verification code",
)
@router.post("/disable-2fa")
def disable_two_factor(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_verified_user),
two_factor_disable: schemas.TwoFactorDisable,
) -> Any:
"""
Disable 2FA.
"""
if not current_user.is_two_factor_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="2FA not enabled",
)
# Verify user's password before disabling 2FA
if not security.verify_password(two_factor_disable.password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect password",
)
# Verify 2FA code before disabling
if not verify_totp(current_user.two_factor_secret, two_factor_disable.code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid 2FA code",
)
crud.user.disable_two_factor(db, user=current_user)
return {"message": "2FA successfully disabled"}
@router.get("/me", response_model=schemas.User)
def read_user_me(
current_user: models.User = Depends(deps.get_current_active_verified_user),
) -> Any:
"""
Get current user.
"""
return current_user

View File

@ -0,0 +1,309 @@
from typing import Any, List
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
from app.models.transaction import TransactionType
from app.models.wallet import WalletType
from app.services.file_upload import save_bot_image
from app.core.email import send_bot_purchase_confirmation
router = APIRouter()
@router.get("/", response_model=List[schemas.Bot])
def read_bots(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_verified_user),
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Retrieve available bots.
"""
bots = crud.bot.get_active(db, skip=skip, limit=limit)
return bots
@router.get("/{bot_id}", response_model=schemas.Bot)
def read_bot(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_verified_user),
bot_id: int,
) -> Any:
"""
Get a specific bot by ID.
"""
bot = crud.bot.get(db, id=bot_id)
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bot not found",
)
if not bot.is_active and current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bot not found or not active",
)
return bot
@router.post("/{bot_id}/purchase", response_model=schemas.BotPurchase)
def purchase_bot(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_verified_user),
bot_id: int,
purchase_data: schemas.BotPurchaseRequest,
) -> Any:
"""
Purchase a bot.
"""
bot = crud.bot.get(db, id=bot_id)
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bot not found",
)
if not bot.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Bot is not active",
)
# Validate purchase amount
if purchase_data.amount < bot.min_purchase_amount:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Minimum purchase amount is {bot.min_purchase_amount} USDT",
)
if purchase_data.amount > bot.max_purchase_amount:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Maximum purchase amount is {bot.max_purchase_amount} USDT",
)
# Get user's trading wallet
trading_wallet = crud.wallet.get_by_user_and_type(
db, user_id=current_user.id, wallet_type=WalletType.TRADING
)
if not trading_wallet:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Trading wallet not found",
)
# Check if user has enough balance
if trading_wallet.balance < purchase_data.amount:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Insufficient funds in trading wallet",
)
# Calculate expected ROI
expected_roi_amount = purchase_data.amount * (bot.roi_percentage / 100)
# Calculate end time
start_time = datetime.utcnow()
end_time = start_time + timedelta(hours=bot.duration_hours)
# Create bot purchase
bot_purchase_in = schemas.BotPurchaseCreate(
user_id=current_user.id,
bot_id=bot.id,
amount=purchase_data.amount,
expected_roi_amount=expected_roi_amount,
start_time=start_time,
end_time=end_time,
status=schemas.BotPurchaseStatus.RUNNING,
)
bot_purchase = crud.bot_purchase.create(db, obj_in=bot_purchase_in)
# Deduct amount from trading wallet
crud.wallet.update_balance(db, wallet_id=trading_wallet.id, amount=purchase_data.amount, add=False)
# Create transaction record
crud.transaction.create(
db,
obj_in=schemas.TransactionCreate(
user_id=current_user.id,
wallet_id=trading_wallet.id,
amount=-purchase_data.amount,
transaction_type=TransactionType.BOT_PURCHASE,
description=f"Bot purchase - {bot.name}",
bot_purchase_id=bot_purchase.id,
),
)
# Send confirmation email
send_bot_purchase_confirmation(
email_to=current_user.email,
bot_name=bot.name,
amount=purchase_data.amount,
expected_roi=expected_roi_amount,
end_date=end_time.strftime("%Y-%m-%d %H:%M:%S UTC"),
)
return bot_purchase
@router.get("/purchased", response_model=List[schemas.BotPurchase])
def read_purchased_bots(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_verified_user),
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Retrieve user's purchased bots.
"""
bot_purchases = crud.bot_purchase.get_by_user(db, user_id=current_user.id, skip=skip, limit=limit)
return bot_purchases
# Admin endpoints
@router.post("/admin", response_model=schemas.Bot)
async def create_bot(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
name: str = Form(...),
description: str = Form(None),
roi_percentage: float = Form(...),
duration_hours: int = Form(...),
min_purchase_amount: float = Form(...),
max_purchase_amount: float = Form(...),
is_active: bool = Form(True),
image: UploadFile = File(None),
) -> Any:
"""
Create a new bot (admin only).
"""
# Check if bot with same name already exists
existing_bot = crud.bot.get_by_name(db, name=name)
if existing_bot:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Bot with this name already exists",
)
# Create bot
bot_in = schemas.BotCreate(
name=name,
description=description,
roi_percentage=roi_percentage,
duration_hours=duration_hours,
min_purchase_amount=min_purchase_amount,
max_purchase_amount=max_purchase_amount,
is_active=is_active,
)
bot = crud.bot.create(db, obj_in=bot_in)
# Save image if provided
if image:
image_path = save_bot_image(bot.id, image)
crud.bot.update(db, db_obj=bot, obj_in={"image_path": image_path})
bot.image_path = image_path
return bot
@router.put("/admin/{bot_id}", response_model=schemas.Bot)
async def update_bot(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
bot_id: int,
name: str = Form(None),
description: str = Form(None),
roi_percentage: float = Form(None),
duration_hours: int = Form(None),
min_purchase_amount: float = Form(None),
max_purchase_amount: float = Form(None),
is_active: bool = Form(None),
image: UploadFile = File(None),
) -> Any:
"""
Update a bot (admin only).
"""
bot = crud.bot.get(db, id=bot_id)
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bot not found",
)
# Check if name is being changed and if it conflicts with existing bot
if name and name != bot.name:
existing_bot = crud.bot.get_by_name(db, name=name)
if existing_bot and existing_bot.id != bot_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Bot with this name already exists",
)
# Update bot data
update_data = {}
if name is not None:
update_data["name"] = name
if description is not None:
update_data["description"] = description
if roi_percentage is not None:
update_data["roi_percentage"] = roi_percentage
if duration_hours is not None:
update_data["duration_hours"] = duration_hours
if min_purchase_amount is not None:
update_data["min_purchase_amount"] = min_purchase_amount
if max_purchase_amount is not None:
update_data["max_purchase_amount"] = max_purchase_amount
if is_active is not None:
update_data["is_active"] = is_active
# Save image if provided
if image:
image_path = save_bot_image(bot.id, image)
update_data["image_path"] = image_path
bot = crud.bot.update(db, db_obj=bot, obj_in=update_data)
return bot
@router.delete("/admin/{bot_id}", response_model=schemas.Bot)
def delete_bot(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
bot_id: int,
) -> Any:
"""
Delete a bot (admin only).
"""
bot = crud.bot.get(db, id=bot_id)
if not bot:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Bot not found",
)
# Check if bot has any active purchases
active_purchases = crud.bot_purchase.get_by_bot(db, bot_id=bot_id)
active_purchases = [p for p in active_purchases if p.status == "running"]
if active_purchases:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete bot with active purchases",
)
bot = crud.bot.remove(db, id=bot_id)
return bot

View File

@ -0,0 +1,207 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
from app.models.transaction import TransactionType
from app.models.wallet import WalletType
from app.services.file_upload import save_deposit_proof
from app.core.email import send_deposit_confirmation
from app.core.config import settings
router = APIRouter()
@router.post("/request", response_model=schemas.Deposit)
async def create_deposit_request(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_verified_user),
amount: float = Form(...),
transaction_hash: str = Form(...),
proof_image: UploadFile = File(...),
) -> Any:
"""
Create a new deposit request.
"""
# Validate minimum deposit amount
if amount < settings.MIN_DEPOSIT_AMOUNT:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Minimum deposit amount is {settings.MIN_DEPOSIT_AMOUNT} USDT",
)
# Save proof image
proof_image_path = save_deposit_proof(current_user.id, proof_image)
# Create deposit request
deposit_in = schemas.DepositCreate(
user_id=current_user.id,
amount=amount,
transaction_hash=transaction_hash,
)
deposit = crud.deposit.create(db, obj_in=deposit_in)
# Update deposit with proof image path
crud.deposit.update(db, db_obj=deposit, obj_in={"proof_image_path": proof_image_path})
return deposit
@router.get("/", response_model=List[schemas.Deposit])
def read_deposits(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_verified_user),
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Retrieve user's deposit requests.
"""
deposits = crud.deposit.get_by_user(db, user_id=current_user.id, skip=skip, limit=limit)
return deposits
@router.get("/{deposit_id}", response_model=schemas.Deposit)
def read_deposit(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_verified_user),
deposit_id: int,
) -> Any:
"""
Get a specific deposit by ID.
"""
deposit = crud.deposit.get(db, id=deposit_id)
if not deposit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deposit not found",
)
if deposit.user_id != current_user.id and current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
return deposit
# Admin endpoints
@router.get("/admin/pending", response_model=List[schemas.Deposit])
def read_pending_deposits(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Retrieve all pending deposit requests (admin only).
"""
deposits = crud.deposit.get_all_pending(db, skip=skip, limit=limit)
return deposits
@router.put("/admin/{deposit_id}/approve", response_model=schemas.Deposit)
def approve_deposit_request(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
deposit_id: int,
deposit_data: schemas.DepositApprove,
) -> Any:
"""
Approve a deposit request (admin only).
"""
deposit = crud.deposit.get(db, id=deposit_id)
if not deposit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deposit not found",
)
if deposit.status != "pending":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Deposit is already {deposit.status}",
)
# Get user's spot wallet
spot_wallet = crud.wallet.get_by_user_and_type(
db, user_id=deposit.user_id, wallet_type=WalletType.SPOT
)
if not spot_wallet:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User's spot wallet not found",
)
# Update wallet balance
crud.wallet.update_balance(db, wallet_id=spot_wallet.id, amount=deposit.amount, add=True)
# Approve deposit
updated_deposit = crud.deposit.approve(
db, db_obj=deposit, admin_notes=deposit_data.admin_notes
)
# Create transaction record
transaction = crud.transaction.create(
db,
obj_in=schemas.TransactionCreate(
user_id=deposit.user_id,
wallet_id=spot_wallet.id,
amount=deposit.amount,
transaction_type=TransactionType.DEPOSIT,
description=f"Deposit - {deposit.transaction_hash}",
deposit_id=deposit.id,
),
)
# Send confirmation email to user
user = crud.user.get(db, id=deposit.user_id)
if user:
send_deposit_confirmation(
email_to=user.email,
amount=deposit.amount,
transaction_id=str(transaction.id),
)
return updated_deposit
@router.put("/admin/{deposit_id}/reject", response_model=schemas.Deposit)
def reject_deposit_request(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
deposit_id: int,
deposit_data: schemas.DepositReject,
) -> Any:
"""
Reject a deposit request (admin only).
"""
deposit = crud.deposit.get(db, id=deposit_id)
if not deposit:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deposit not found",
)
if deposit.status != "pending":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Deposit is already {deposit.status}",
)
# Reject deposit
updated_deposit = crud.deposit.reject(
db, db_obj=deposit, admin_notes=deposit_data.admin_notes
)
return updated_deposit

201
app/api/v1/endpoints/kyc.py Normal file
View File

@ -0,0 +1,201 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
from app.services.file_upload import save_kyc_document
from app.core.email import send_kyc_status_update
router = APIRouter()
@router.post("/upload", response_model=schemas.KYC)
async def upload_kyc_documents(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_verified_user),
full_name: str = Form(...),
id_document_type: schemas.IDDocumentType = Form(...),
id_document: UploadFile = File(...),
selfie: UploadFile = File(...),
additional_document: UploadFile = File(None),
) -> Any:
"""
Upload KYC documents.
"""
# Check if user already has KYC submission
existing_kyc = crud.kyc.get_by_user(db, user_id=current_user.id)
if existing_kyc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"You already have a KYC submission with status: {existing_kyc.status}",
)
# Save documents
id_document_path = save_kyc_document(current_user.id, "id_document", id_document)
selfie_path = save_kyc_document(current_user.id, "selfie", selfie)
additional_document_path = None
if additional_document:
additional_document_path = save_kyc_document(current_user.id, "additional", additional_document)
# Create KYC record
kyc_in = schemas.KYCCreate(
user_id=current_user.id,
full_name=full_name,
id_document_type=id_document_type,
id_document_path=id_document_path,
selfie_path=selfie_path,
additional_document_path=additional_document_path,
)
kyc = crud.kyc.create(db, obj_in=kyc_in)
return kyc
@router.get("/status", response_model=schemas.KYC)
def get_kyc_status(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_verified_user),
) -> Any:
"""
Get user's KYC status.
"""
kyc = crud.kyc.get_by_user(db, user_id=current_user.id)
if not kyc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No KYC submission found",
)
return kyc
# Admin endpoints
@router.get("/admin/pending", response_model=List[schemas.KYC])
def get_pending_kyc(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Get all pending KYC submissions (admin only).
"""
kyc_submissions = crud.kyc.get_all_pending(db, skip=skip, limit=limit)
return kyc_submissions
@router.get("/admin/{kyc_id}", response_model=schemas.KYC)
def get_kyc(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
kyc_id: int,
) -> Any:
"""
Get a specific KYC submission by ID (admin only).
"""
kyc = crud.kyc.get(db, id=kyc_id)
if not kyc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="KYC submission not found",
)
return kyc
@router.put("/admin/{kyc_id}/approve", response_model=schemas.KYC)
def approve_kyc_submission(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
kyc_id: int,
) -> Any:
"""
Approve a KYC submission (admin only).
"""
kyc = crud.kyc.get(db, id=kyc_id)
if not kyc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="KYC submission not found",
)
if kyc.status != "pending":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"KYC submission is already {kyc.status}",
)
# Get user
user = crud.user.get(db, id=kyc.user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update KYC status
updated_kyc = crud.kyc.approve(db, db_obj=kyc)
# Update user's KYC status
crud.user.set_kyc_verified(db, user=user)
# Send email notification
send_kyc_status_update(
email_to=user.email,
status="approved",
)
return updated_kyc
@router.put("/admin/{kyc_id}/reject", response_model=schemas.KYC)
def reject_kyc_submission(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
kyc_id: int,
kyc_data: schemas.KYCReject,
) -> Any:
"""
Reject a KYC submission (admin only).
"""
kyc = crud.kyc.get(db, id=kyc_id)
if not kyc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="KYC submission not found",
)
if kyc.status != "pending":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"KYC submission is already {kyc.status}",
)
# Get user
user = crud.user.get(db, id=kyc.user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Update KYC status
updated_kyc = crud.kyc.reject(db, db_obj=kyc, rejection_reason=kyc_data.rejection_reason)
# Send email notification
send_kyc_status_update(
email_to=user.email,
status="rejected",
reason=kyc_data.rejection_reason,
)
return updated_kyc

View File

@ -0,0 +1,145 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
from app.models.transaction import TransactionType
router = APIRouter()
@router.get("/", response_model=List[schemas.Wallet])
def read_wallets(
current_user: models.User = Depends(deps.get_current_active_verified_user),
db: Session = Depends(deps.get_db),
) -> Any:
"""
Retrieve user's wallets.
"""
wallets = crud.wallet.get_by_user(db, user_id=current_user.id)
return wallets
@router.get("/{wallet_type}", response_model=schemas.Wallet)
def read_wallet(
wallet_type: schemas.WalletType,
current_user: models.User = Depends(deps.get_current_active_verified_user),
db: Session = Depends(deps.get_db),
) -> Any:
"""
Get a specific wallet by type.
"""
wallet = crud.wallet.get_by_user_and_type(
db, user_id=current_user.id, wallet_type=wallet_type
)
if not wallet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Wallet of type {wallet_type} not found",
)
return wallet
@router.post("/transfer", response_model=dict)
def transfer_funds(
*,
transfer_data: schemas.WalletTransfer,
current_user: models.User = Depends(deps.get_current_active_verified_user),
db: Session = Depends(deps.get_db),
) -> Any:
"""
Transfer funds between wallets.
"""
if transfer_data.from_wallet_type == transfer_data.to_wallet_type:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot transfer between the same wallet type",
)
from_wallet = crud.wallet.get_by_user_and_type(
db, user_id=current_user.id, wallet_type=transfer_data.from_wallet_type
)
if not from_wallet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Source wallet of type {transfer_data.from_wallet_type} not found",
)
if from_wallet.balance < transfer_data.amount:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Insufficient funds in source wallet",
)
to_wallet = crud.wallet.get_by_user_and_type(
db, user_id=current_user.id, wallet_type=transfer_data.to_wallet_type
)
if not to_wallet:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Destination wallet of type {transfer_data.to_wallet_type} not found",
)
from_wallet, to_wallet = crud.wallet.transfer(
db,
user_id=current_user.id,
from_type=transfer_data.from_wallet_type,
to_type=transfer_data.to_wallet_type,
amount=transfer_data.amount,
)
if not from_wallet or not to_wallet:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Transfer failed",
)
# Create transfer transaction records
# First, create the outgoing transaction
outgoing_transaction = crud.transaction.create(
db,
obj_in=schemas.TransactionCreate(
user_id=current_user.id,
wallet_id=from_wallet.id,
amount=-transfer_data.amount,
transaction_type=TransactionType.TRANSFER,
description=f"Transfer to {transfer_data.to_wallet_type} wallet",
),
)
# Then, create the incoming transaction
incoming_transaction = crud.transaction.create(
db,
obj_in=schemas.TransactionCreate(
user_id=current_user.id,
wallet_id=to_wallet.id,
amount=transfer_data.amount,
transaction_type=TransactionType.TRANSFER,
description=f"Transfer from {transfer_data.from_wallet_type} wallet",
related_transaction_id=outgoing_transaction.id,
),
)
# Update the related transaction ID for the outgoing transaction
crud.transaction.update(
db,
db_obj=outgoing_transaction,
obj_in={"related_transaction_id": incoming_transaction.id},
)
return {
"message": f"Successfully transferred {transfer_data.amount} USDT from {transfer_data.from_wallet_type} wallet to {transfer_data.to_wallet_type} wallet",
"from_wallet": {
"type": from_wallet.wallet_type,
"balance": from_wallet.balance,
},
"to_wallet": {
"type": to_wallet.wallet_type,
"balance": to_wallet.balance,
},
}

View File

@ -0,0 +1,249 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import crud, models, schemas
from app.api import deps
from app.models.transaction import TransactionType
from app.models.wallet import WalletType
from app.core.email import send_withdrawal_confirmation
from app.core.config import settings
router = APIRouter()
@router.post("/request", response_model=schemas.Withdrawal)
def create_withdrawal_request(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_verified_user),
withdrawal_data: schemas.WithdrawalRequest,
) -> Any:
"""
Create a new withdrawal request.
"""
# Check if user is KYC verified (if required)
if not current_user.is_kyc_verified:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="KYC verification required for withdrawals",
)
# Validate minimum withdrawal amount
if withdrawal_data.amount < settings.MIN_WITHDRAWAL_AMOUNT:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Minimum withdrawal amount is {settings.MIN_WITHDRAWAL_AMOUNT} USDT",
)
# Get user's spot wallet
spot_wallet = crud.wallet.get_by_user_and_type(
db, user_id=current_user.id, wallet_type=WalletType.SPOT
)
if not spot_wallet:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Spot wallet not found",
)
# Calculate fee
fee = withdrawal_data.amount * (settings.WITHDRAWAL_FEE_PERCENTAGE / 100)
total_amount = withdrawal_data.amount + fee
# Check if user has enough balance
if spot_wallet.balance < total_amount:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Insufficient funds. Required: {total_amount} USDT (including {fee} USDT fee)",
)
# Create withdrawal request
withdrawal_in = schemas.WithdrawalCreate(
user_id=current_user.id,
amount=withdrawal_data.amount,
fee=fee,
wallet_address=withdrawal_data.wallet_address,
)
withdrawal = crud.withdrawal.create(db, obj_in=withdrawal_in)
# Reserve the funds by reducing the wallet balance
crud.wallet.update_balance(db, wallet_id=spot_wallet.id, amount=total_amount, add=False)
# Create transaction record for the reservation
crud.transaction.create(
db,
obj_in=schemas.TransactionCreate(
user_id=current_user.id,
wallet_id=spot_wallet.id,
amount=-total_amount,
transaction_type=TransactionType.WITHDRAWAL,
description="Withdrawal request - Reserved funds",
withdrawal_id=withdrawal.id,
),
)
return withdrawal
@router.get("/", response_model=List[schemas.Withdrawal])
def read_withdrawals(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_verified_user),
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Retrieve user's withdrawal requests.
"""
withdrawals = crud.withdrawal.get_by_user(db, user_id=current_user.id, skip=skip, limit=limit)
return withdrawals
@router.get("/{withdrawal_id}", response_model=schemas.Withdrawal)
def read_withdrawal(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_active_verified_user),
withdrawal_id: int,
) -> Any:
"""
Get a specific withdrawal by ID.
"""
withdrawal = crud.withdrawal.get(db, id=withdrawal_id)
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Withdrawal not found",
)
if withdrawal.user_id != current_user.id and current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
return withdrawal
# Admin endpoints
@router.get("/admin/pending", response_model=List[schemas.Withdrawal])
def read_pending_withdrawals(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
skip: int = 0,
limit: int = 100,
) -> Any:
"""
Retrieve all pending withdrawal requests (admin only).
"""
withdrawals = crud.withdrawal.get_all_pending(db, skip=skip, limit=limit)
return withdrawals
@router.put("/admin/{withdrawal_id}/approve", response_model=schemas.Withdrawal)
def approve_withdrawal_request(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
withdrawal_id: int,
withdrawal_data: schemas.WithdrawalApprove,
) -> Any:
"""
Approve a withdrawal request (admin only).
"""
withdrawal = crud.withdrawal.get(db, id=withdrawal_id)
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Withdrawal not found",
)
if withdrawal.status != "pending":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Withdrawal is already {withdrawal.status}",
)
# Approve withdrawal
updated_withdrawal = crud.withdrawal.approve(
db,
db_obj=withdrawal,
transaction_hash=withdrawal_data.transaction_hash,
admin_notes=withdrawal_data.admin_notes
)
# Send confirmation email to user
user = crud.user.get(db, id=withdrawal.user_id)
if user:
send_withdrawal_confirmation(
email_to=user.email,
amount=withdrawal.amount,
transaction_id=withdrawal_data.transaction_hash,
)
return updated_withdrawal
@router.put("/admin/{withdrawal_id}/reject", response_model=schemas.Withdrawal)
def reject_withdrawal_request(
*,
db: Session = Depends(deps.get_db),
current_user: models.User = Depends(deps.get_current_admin),
withdrawal_id: int,
withdrawal_data: schemas.WithdrawalReject,
) -> Any:
"""
Reject a withdrawal request (admin only).
"""
withdrawal = crud.withdrawal.get(db, id=withdrawal_id)
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Withdrawal not found",
)
if withdrawal.status != "pending":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Withdrawal is already {withdrawal.status}",
)
# Get user's spot wallet
spot_wallet = crud.wallet.get_by_user_and_type(
db, user_id=withdrawal.user_id, wallet_type=WalletType.SPOT
)
if not spot_wallet:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User's spot wallet not found",
)
# Calculate total amount (amount + fee)
total_amount = withdrawal.amount + withdrawal.fee
# Refund the reserved funds
crud.wallet.update_balance(db, wallet_id=spot_wallet.id, amount=total_amount, add=True)
# Create transaction record for the refund
crud.transaction.create(
db,
obj_in=schemas.TransactionCreate(
user_id=withdrawal.user_id,
wallet_id=spot_wallet.id,
amount=total_amount,
transaction_type=TransactionType.ADMIN_ADJUSTMENT,
description="Withdrawal rejected - Funds returned",
withdrawal_id=withdrawal.id,
),
)
# Reject withdrawal
updated_withdrawal = crud.withdrawal.reject(
db, db_obj=withdrawal, admin_notes=withdrawal_data.admin_notes
)
return updated_withdrawal

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

View File

@ -0,0 +1,94 @@
import logging
import asyncio
from typing import List, Dict, Any, Callable, Awaitable
from app.core.config import settings
from app.services.bot_simulation import process_completed_bot_purchases
from app.db.session import SessionLocal
logger = logging.getLogger(__name__)
class BackgroundTaskManager:
def __init__(self):
self.tasks: List[Dict[str, Any]] = []
self.is_running = False
def add_task(
self,
name: str,
func: Callable[..., Awaitable[Any]],
interval_seconds: int,
**kwargs
) -> None:
"""Add a task to be executed periodically."""
self.tasks.append({
"name": name,
"func": func,
"interval_seconds": interval_seconds,
"kwargs": kwargs,
"last_run": None,
})
logger.info(f"Added background task: {name}")
async def start(self) -> None:
"""Start all background tasks."""
if self.is_running:
return
self.is_running = True
logger.info("Starting background tasks")
tasks = []
for task_info in self.tasks:
tasks.append(self._run_task_periodically(task_info))
await asyncio.gather(*tasks)
async def _run_task_periodically(self, task_info: Dict[str, Any]) -> None:
"""Run a task periodically at the specified interval."""
name = task_info["name"]
func = task_info["func"]
interval_seconds = task_info["interval_seconds"]
kwargs = task_info["kwargs"]
logger.info(f"Starting periodic task: {name}")
while self.is_running:
try:
logger.debug(f"Running task: {name}")
await func(**kwargs)
logger.debug(f"Task completed: {name}")
except Exception as e:
logger.error(f"Error in task {name}: {str(e)}")
# Sleep until next interval
await asyncio.sleep(interval_seconds)
def stop(self) -> None:
"""Stop all background tasks."""
self.is_running = False
logger.info("Stopping background tasks")
# Define our background tasks
async def process_bot_purchases() -> None:
"""Process completed bot purchases."""
db = SessionLocal()
try:
count = process_completed_bot_purchases(db)
if count > 0:
logger.info(f"Processed {count} completed bot purchases")
finally:
db.close()
# Create the task manager instance
task_manager = BackgroundTaskManager()
# Add the bot simulation task
task_manager.add_task(
"process_bot_purchases",
process_bot_purchases,
interval_seconds=settings.BOT_SIMULATION_INTERVAL,
)

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

@ -0,0 +1,74 @@
import os
import secrets
from pathlib import Path
from typing import List, Union, Optional
from pydantic import EmailStr, validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Project settings
PROJECT_NAME: str = "Deft Trade"
PROJECT_DESCRIPTION: str = "DeFi Trading Simulation Platform"
VERSION: str = "0.1.0"
API_V1_STR: str = "/api/v1"
# Security settings
SECRET_KEY: str = os.environ.get("SECRET_KEY") or secrets.token_urlsafe(32)
JWT_SECRET_KEY: str = os.environ.get("JWT_SECRET_KEY") or secrets.token_urlsafe(32)
ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES", 30))
REFRESH_TOKEN_EXPIRE_DAYS: int = int(os.environ.get("REFRESH_TOKEN_EXPIRE_DAYS", 7))
ALGORITHM: str = "HS256"
# CORS settings
BACKEND_CORS_ORIGINS: List[str] = ["*"]
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> Union[List[str], str]:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, (list, str)):
return v
raise ValueError(v)
# Database
DB_DIR: Path = Path("/app") / "storage" / "db"
# Email settings
EMAILS_ENABLED: bool = os.environ.get("EMAILS_ENABLED", "False").lower() == "true"
SMTP_TLS: bool = os.environ.get("SMTP_TLS", "True").lower() == "true"
SMTP_PORT: Optional[int] = int(os.environ.get("SMTP_PORT", 587)) if os.environ.get("SMTP_PORT") else None
SMTP_HOST: Optional[str] = os.environ.get("SMTP_HOST")
SMTP_USER: Optional[str] = os.environ.get("SMTP_USER")
SMTP_PASSWORD: Optional[str] = os.environ.get("SMTP_PASSWORD")
EMAILS_FROM_EMAIL: Optional[EmailStr] = os.environ.get("EMAILS_FROM_EMAIL")
EMAILS_FROM_NAME: Optional[str] = os.environ.get("EMAILS_FROM_NAME")
# File upload
UPLOAD_DIR: Path = Path("/app") / "storage" / "uploads"
KYC_UPLOAD_DIR: Path = Path("/app") / "storage" / "kyc"
DEPOSIT_PROOFS_DIR: Path = Path("/app") / "storage" / "deposit_proofs"
MAX_UPLOAD_SIZE: int = int(os.environ.get("MAX_UPLOAD_SIZE", 5 * 1024 * 1024)) # 5 MB default
# Admin default settings
ADMIN_EMAIL: EmailStr = os.environ.get("ADMIN_EMAIL", "admin@defttrade.com")
ADMIN_PASSWORD: str = os.environ.get("ADMIN_PASSWORD", "change-me-please")
# 2FA settings
TWO_FACTOR_REQUIRED: bool = os.environ.get("TWO_FACTOR_REQUIRED", "False").lower() == "true"
# Bot simulation settings
BOT_SIMULATION_INTERVAL: int = int(os.environ.get("BOT_SIMULATION_INTERVAL", 60)) # Seconds
# Transaction settings
MIN_DEPOSIT_AMOUNT: float = float(os.environ.get("MIN_DEPOSIT_AMOUNT", 10.0))
MIN_WITHDRAWAL_AMOUNT: float = float(os.environ.get("MIN_WITHDRAWAL_AMOUNT", 10.0))
WITHDRAWAL_FEE_PERCENTAGE: float = float(os.environ.get("WITHDRAWAL_FEE_PERCENTAGE", 1.0))
class Config:
case_sensitive = True
env_file = ".env"
settings = Settings()

262
app/core/email.py Normal file
View File

@ -0,0 +1,262 @@
import logging
from typing import Any, Dict, Optional
import emails
from emails.template import JinjaTemplate
from app.core.config import settings
def send_email(
email_to: str,
subject_template: str = "",
html_template: str = "",
environment: Optional[Dict[str, Any]] = None,
) -> None:
if not settings.EMAILS_ENABLED:
logging.warning("Email feature is disabled. Would have sent email to: %s", email_to)
return
if environment is None:
environment = {}
message = emails.Message(
subject=JinjaTemplate(subject_template),
html=JinjaTemplate(html_template),
mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL),
)
smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT}
if settings.SMTP_TLS:
smtp_options["tls"] = True
if settings.SMTP_USER:
smtp_options["user"] = settings.SMTP_USER
if settings.SMTP_PASSWORD:
smtp_options["password"] = settings.SMTP_PASSWORD
response = message.send(to=email_to, render=environment, smtp=smtp_options)
logging.info("Email sent to %s, response: %s", email_to, response)
def send_test_email(email_to: str) -> None:
project_name = settings.PROJECT_NAME
subject = f"{project_name} - Test email"
html_template = """
<html>
<body>
<p>Hi,</p>
<p>This is a test email from {project_name}.</p>
</body>
</html>
"""
send_email(
email_to=email_to,
subject_template=subject,
html_template=html_template,
environment={"project_name": project_name},
)
def send_email_verification(email_to: str, token: str) -> None:
project_name = settings.PROJECT_NAME
subject = f"{project_name} - Verify your email"
verification_url = f"http://localhost:8000/api/v1/auth/verify-email?token={token}"
html_template = """
<html>
<body>
<p>Hi,</p>
<p>Thanks for signing up for {project_name}.</p>
<p>Please verify your email address by clicking on the link below:</p>
<p><a href="{verification_url}">{verification_url}</a></p>
<p>The link is valid for 24 hours.</p>
</body>
</html>
"""
send_email(
email_to=email_to,
subject_template=subject,
html_template=html_template,
environment={
"project_name": project_name,
"verification_url": verification_url,
},
)
def send_password_reset(email_to: str, token: str) -> None:
project_name = settings.PROJECT_NAME
subject = f"{project_name} - Password Reset"
reset_url = f"http://localhost:8000/api/v1/auth/reset-password?token={token}"
html_template = """
<html>
<body>
<p>Hi,</p>
<p>You have requested to reset your password for {project_name}.</p>
<p>Please click the link below to reset your password:</p>
<p><a href="{reset_url}">{reset_url}</a></p>
<p>The link is valid for 24 hours.</p>
<p>If you didn't request a password reset, please ignore this email.</p>
</body>
</html>
"""
send_email(
email_to=email_to,
subject_template=subject,
html_template=html_template,
environment={
"project_name": project_name,
"reset_url": reset_url,
},
)
def send_deposit_confirmation(email_to: str, amount: float, transaction_id: str) -> None:
project_name = settings.PROJECT_NAME
subject = f"{project_name} - Deposit Confirmed"
html_template = """
<html>
<body>
<p>Hi,</p>
<p>Your deposit of {amount} USDT has been confirmed and added to your account.</p>
<p>Transaction ID: {transaction_id}</p>
<p>Thank you for using {project_name}!</p>
</body>
</html>
"""
send_email(
email_to=email_to,
subject_template=subject,
html_template=html_template,
environment={
"project_name": project_name,
"amount": amount,
"transaction_id": transaction_id,
},
)
def send_withdrawal_confirmation(email_to: str, amount: float, transaction_id: str) -> None:
project_name = settings.PROJECT_NAME
subject = f"{project_name} - Withdrawal Processed"
html_template = """
<html>
<body>
<p>Hi,</p>
<p>Your withdrawal of {amount} USDT has been processed.</p>
<p>Transaction ID: {transaction_id}</p>
<p>Thank you for using {project_name}!</p>
</body>
</html>
"""
send_email(
email_to=email_to,
subject_template=subject,
html_template=html_template,
environment={
"project_name": project_name,
"amount": amount,
"transaction_id": transaction_id,
},
)
def send_bot_purchase_confirmation(email_to: str, bot_name: str, amount: float, expected_roi: float, end_date: str) -> None:
project_name = settings.PROJECT_NAME
subject = f"{project_name} - Bot Purchase Confirmation"
html_template = """
<html>
<body>
<p>Hi,</p>
<p>Your purchase of the {bot_name} bot for {amount} USDT has been confirmed.</p>
<p>Expected ROI: {expected_roi} USDT</p>
<p>End Date: {end_date}</p>
<p>Thank you for using {project_name}!</p>
</body>
</html>
"""
send_email(
email_to=email_to,
subject_template=subject,
html_template=html_template,
environment={
"project_name": project_name,
"bot_name": bot_name,
"amount": amount,
"expected_roi": expected_roi,
"end_date": end_date,
},
)
def send_bot_completion_notification(email_to: str, bot_name: str, amount: float, roi: float) -> None:
project_name = settings.PROJECT_NAME
subject = f"{project_name} - Bot Trading Completed"
html_template = """
<html>
<body>
<p>Hi,</p>
<p>Your {bot_name} bot has completed its trading cycle.</p>
<p>Principal: {amount} USDT</p>
<p>ROI: {roi} USDT</p>
<p>Total: {total} USDT</p>
<p>The funds have been credited to your Trading Wallet.</p>
<p>Thank you for using {project_name}!</p>
</body>
</html>
"""
total = amount + roi
send_email(
email_to=email_to,
subject_template=subject,
html_template=html_template,
environment={
"project_name": project_name,
"bot_name": bot_name,
"amount": amount,
"roi": roi,
"total": total,
},
)
def send_kyc_status_update(email_to: str, status: str, reason: Optional[str] = None) -> None:
project_name = settings.PROJECT_NAME
subject = f"{project_name} - KYC Verification {status.capitalize()}"
html_template = """
<html>
<body>
<p>Hi,</p>
<p>Your KYC verification has been {status}.</p>
{reason_html}
<p>Thank you for using {project_name}!</p>
</body>
</html>
"""
reason_html = f"<p>Reason: {reason}</p>" if reason else ""
send_email(
email_to=email_to,
subject_template=subject,
html_template=html_template,
environment={
"project_name": project_name,
"status": status,
"reason_html": reason_html,
},
)

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

@ -0,0 +1,70 @@
from datetime import datetime, timedelta
from typing import Any, Optional, Union
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def create_access_token(
subject: Union[str, Any], role: str, expires_delta: Optional[timedelta] = None
) -> str:
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {"exp": expire, "sub": str(subject), "role": role}
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_refresh_token(
subject: Union[str, Any], role: str, expires_delta: Optional[timedelta] = None
) -> str:
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode = {"exp": expire, "sub": str(subject), "role": role}
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_email_verification_token(email: str) -> str:
expire = datetime.utcnow() + timedelta(hours=24)
to_encode = {"exp": expire, "sub": email, "type": "email_verification"}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def create_password_reset_token(email: str) -> str:
expire = datetime.utcnow() + timedelta(hours=24)
to_encode = {"exp": expire, "sub": email, "type": "password_reset"}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def verify_token(token: str, token_type: str) -> Optional[str]:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
if payload.get("type") != token_type:
return None
return payload.get("sub")
except jwt.JWTError:
return None

0
app/crud/__init__.py Normal file
View File

66
app/crud/base.py Normal file
View File

@ -0,0 +1,66 @@
from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.db.base_class import Base
ModelType = TypeVar("ModelType", bound=Base)
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
def __init__(self, model: Type[ModelType]):
"""
CRUD object with default methods to Create, Read, Update, Delete (CRUD).
**Parameters**
* `model`: A SQLAlchemy model class
* `schema`: A Pydantic model (schema) class
"""
self.model = model
def get(self, db: Session, id: Any) -> Optional[ModelType]:
return db.query(self.model).filter(self.model.id == id).first()
def get_multi(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[ModelType]:
return db.query(self.model).offset(skip).limit(limit).all()
def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
obj_in_data = jsonable_encoder(obj_in)
db_obj = self.model(**obj_in_data)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self,
db: Session,
*,
db_obj: ModelType,
obj_in: Union[UpdateSchemaType, Dict[str, Any]]
) -> ModelType:
obj_data = jsonable_encoder(db_obj)
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.dict(exclude_unset=True)
for field in obj_data:
if field in update_data:
setattr(db_obj, field, update_data[field])
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def remove(self, db: Session, *, id: int) -> ModelType:
obj = db.query(self.model).get(id)
db.delete(obj)
db.commit()
return obj

43
app/crud/crud_bot.py Normal file
View File

@ -0,0 +1,43 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.bot import Bot
from app.schemas.bot import BotCreate, BotUpdate
class CRUDBot(CRUDBase[Bot, BotCreate, BotUpdate]):
def get_active(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[Bot]:
return (
db.query(Bot)
.filter(Bot.is_active)
.offset(skip)
.limit(limit)
.all()
)
def get_by_name(
self, db: Session, *, name: str
) -> Optional[Bot]:
return db.query(Bot).filter(Bot.name == name).first()
def get_all(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[Bot]:
return db.query(Bot).offset(skip).limit(limit).all()
bot = CRUDBot(Bot)
# Aliases for convenience
get_bot = bot.get
create_bot = bot.create
update_bot = bot.update
delete_bot = bot.remove
get_active_bots = bot.get_active
get_bot_by_name = bot.get_by_name
get_all_bots = bot.get_all

View File

@ -0,0 +1,89 @@
from typing import List
from datetime import datetime
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.bot_purchase import BotPurchase, BotPurchaseStatus
from app.schemas.bot_purchase import BotPurchaseCreate, BotPurchaseUpdate
class CRUDBotPurchase(CRUDBase[BotPurchase, BotPurchaseCreate, BotPurchaseUpdate]):
def get_by_user(
self, db: Session, *, user_id: int, skip: int = 0, limit: int = 100
) -> List[BotPurchase]:
return (
db.query(BotPurchase)
.filter(BotPurchase.user_id == user_id)
.order_by(BotPurchase.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def get_by_bot(
self, db: Session, *, bot_id: int, skip: int = 0, limit: int = 100
) -> List[BotPurchase]:
return (
db.query(BotPurchase)
.filter(BotPurchase.bot_id == bot_id)
.order_by(BotPurchase.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def get_by_status(
self, db: Session, *, status: BotPurchaseStatus, skip: int = 0, limit: int = 100
) -> List[BotPurchase]:
return (
db.query(BotPurchase)
.filter(BotPurchase.status == status)
.order_by(BotPurchase.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def get_completed_due(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[BotPurchase]:
now = datetime.utcnow()
return (
db.query(BotPurchase)
.filter(
BotPurchase.status == BotPurchaseStatus.RUNNING,
BotPurchase.end_time <= now
)
.order_by(BotPurchase.end_time)
.offset(skip)
.limit(limit)
.all()
)
def complete(
self, db: Session, *, db_obj: BotPurchase
) -> BotPurchase:
update_data = {"status": BotPurchaseStatus.COMPLETED}
return super().update(db, db_obj=db_obj, obj_in=update_data)
def cancel(
self, db: Session, *, db_obj: BotPurchase
) -> BotPurchase:
update_data = {"status": BotPurchaseStatus.CANCELLED}
return super().update(db, db_obj=db_obj, obj_in=update_data)
bot_purchase = CRUDBotPurchase(BotPurchase)
# Aliases for convenience
get_bot_purchase = bot_purchase.get
create_bot_purchase = bot_purchase.create
update_bot_purchase = bot_purchase.update
get_bot_purchases_by_user = bot_purchase.get_by_user
get_bot_purchases_by_bot = bot_purchase.get_by_bot
get_bot_purchases_by_status = bot_purchase.get_by_status
get_completed_due_bot_purchases = bot_purchase.get_completed_due
complete_bot_purchase = bot_purchase.complete
cancel_bot_purchase = bot_purchase.cancel

70
app/crud/crud_deposit.py Normal file
View File

@ -0,0 +1,70 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.deposit import Deposit, DepositStatus
from app.schemas.deposit import DepositCreate, DepositUpdate
class CRUDDeposit(CRUDBase[Deposit, DepositCreate, DepositUpdate]):
def get_by_user(
self, db: Session, *, user_id: int, skip: int = 0, limit: int = 100
) -> List[Deposit]:
return (
db.query(Deposit)
.filter(Deposit.user_id == user_id)
.order_by(Deposit.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def get_by_status(
self, db: Session, *, status: str, skip: int = 0, limit: int = 100
) -> List[Deposit]:
return (
db.query(Deposit)
.filter(Deposit.status == status)
.order_by(Deposit.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def get_all_pending(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[Deposit]:
return self.get_by_status(db, status=DepositStatus.PENDING, skip=skip, limit=limit)
def approve(
self, db: Session, *, db_obj: Deposit, admin_notes: Optional[str] = None
) -> Deposit:
update_data = {"status": DepositStatus.APPROVED}
if admin_notes:
update_data["admin_notes"] = admin_notes
return super().update(db, db_obj=db_obj, obj_in=update_data)
def reject(
self, db: Session, *, db_obj: Deposit, admin_notes: str
) -> Deposit:
update_data = {
"status": DepositStatus.REJECTED,
"admin_notes": admin_notes
}
return super().update(db, db_obj=db_obj, obj_in=update_data)
deposit = CRUDDeposit(Deposit)
# Aliases for convenience
get_deposit = deposit.get
create_deposit = deposit.create
get_deposits_by_user = deposit.get_by_user
get_deposits_by_status = deposit.get_by_status
get_all_pending_deposits = deposit.get_all_pending
approve_deposit = deposit.approve
reject_deposit = deposit.reject

59
app/crud/crud_kyc.py Normal file
View File

@ -0,0 +1,59 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.kyc import KYC, KYCStatus
from app.schemas.kyc import KYCCreate, KYCUpdate
class CRUDKYC(CRUDBase[KYC, KYCCreate, KYCUpdate]):
def get_by_user(
self, db: Session, *, user_id: int
) -> Optional[KYC]:
return db.query(KYC).filter(KYC.user_id == user_id).first()
def get_by_status(
self, db: Session, *, status: str, skip: int = 0, limit: int = 100
) -> List[KYC]:
return (
db.query(KYC)
.filter(KYC.status == status)
.order_by(KYC.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def get_all_pending(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[KYC]:
return self.get_by_status(db, status=KYCStatus.PENDING, skip=skip, limit=limit)
def approve(
self, db: Session, *, db_obj: KYC
) -> KYC:
update_data = {"status": KYCStatus.APPROVED}
return super().update(db, db_obj=db_obj, obj_in=update_data)
def reject(
self, db: Session, *, db_obj: KYC, rejection_reason: str
) -> KYC:
update_data = {
"status": KYCStatus.REJECTED,
"rejection_reason": rejection_reason
}
return super().update(db, db_obj=db_obj, obj_in=update_data)
kyc = CRUDKYC(KYC)
# Aliases for convenience
get_kyc = kyc.get
create_kyc = kyc.create
get_kyc_by_user = kyc.get_by_user
get_kyc_by_status = kyc.get_by_status
get_all_pending_kyc = kyc.get_all_pending
approve_kyc = kyc.approve
reject_kyc = kyc.reject

View File

@ -0,0 +1,69 @@
from typing import List
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.transaction import Transaction
from app.schemas.transaction import TransactionCreate, TransactionUpdate
class CRUDTransaction(CRUDBase[Transaction, TransactionCreate, TransactionUpdate]):
def get_by_user(
self, db: Session, *, user_id: int, skip: int = 0, limit: int = 100
) -> List[Transaction]:
return (
db.query(Transaction)
.filter(Transaction.user_id == user_id)
.order_by(Transaction.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def get_by_wallet(
self, db: Session, *, wallet_id: int, skip: int = 0, limit: int = 100
) -> List[Transaction]:
return (
db.query(Transaction)
.filter(Transaction.wallet_id == wallet_id)
.order_by(Transaction.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def get_by_type(
self, db: Session, *, transaction_type: str, skip: int = 0, limit: int = 100
) -> List[Transaction]:
return (
db.query(Transaction)
.filter(Transaction.transaction_type == transaction_type)
.order_by(Transaction.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def get_all(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[Transaction]:
return (
db.query(Transaction)
.order_by(Transaction.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
transaction = CRUDTransaction(Transaction)
# Aliases for convenience
get_transaction = transaction.get
create_transaction = transaction.create
update_transaction = transaction.update
get_transactions_by_user = transaction.get_by_user
get_transactions_by_wallet = transaction.get_by_wallet
get_transactions_by_type = transaction.get_by_type
get_all_transactions = transaction.get_all

114
app/crud/crud_user.py Normal file
View File

@ -0,0 +1,114 @@
from typing import Any, Dict, Optional, Union
from sqlalchemy.orm import Session
from app.core.security import get_password_hash, verify_password
from app.crud.base import CRUDBase
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
def get_by_email(self, db: Session, *, email: str) -> Optional[User]:
return db.query(User).filter(User.email == email).first()
def create(self, db: Session, *, obj_in: UserCreate) -> User:
db_obj = User(
email=obj_in.email,
hashed_password=get_password_hash(obj_in.password),
full_name=obj_in.full_name,
role=obj_in.role,
is_active=obj_in.is_active,
is_verified=False,
is_kyc_verified=False,
is_two_factor_enabled=False,
)
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
def update(
self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]
) -> User:
if isinstance(obj_in, dict):
update_data = obj_in
else:
update_data = obj_in.dict(exclude_unset=True)
if "password" in update_data and update_data["password"]:
hashed_password = get_password_hash(update_data["password"])
del update_data["password"]
update_data["hashed_password"] = hashed_password
return super().update(db, db_obj=db_obj, obj_in=update_data)
def authenticate(self, db: Session, *, email: str, password: str) -> Optional[User]:
user = self.get_by_email(db, email=email)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def is_active(self, user: User) -> bool:
return user.is_active
def is_verified(self, user: User) -> bool:
return user.is_verified
def is_admin(self, user: User) -> bool:
return user.role == "admin"
def set_verified(self, db: Session, *, user: User) -> User:
user.is_verified = True
db.add(user)
db.commit()
db.refresh(user)
return user
def set_kyc_verified(self, db: Session, *, user: User) -> User:
user.is_kyc_verified = True
db.add(user)
db.commit()
db.refresh(user)
return user
def set_two_factor_secret(self, db: Session, *, user: User, secret: str) -> User:
user.two_factor_secret = secret
db.add(user)
db.commit()
db.refresh(user)
return user
def enable_two_factor(self, db: Session, *, user: User) -> User:
user.is_two_factor_enabled = True
db.add(user)
db.commit()
db.refresh(user)
return user
def disable_two_factor(self, db: Session, *, user: User) -> User:
user.is_two_factor_enabled = False
user.two_factor_secret = None
db.add(user)
db.commit()
db.refresh(user)
return user
user = CRUDUser(User)
# Aliases for convenience
get_user = user.get
get_user_by_email = user.get_by_email
create_user = user.create
update_user = user.update
authenticate_user = user.authenticate
is_active_user = user.is_active
is_verified_user = user.is_verified
is_admin_user = user.is_admin
set_user_verified = user.set_verified
set_user_kyc_verified = user.set_kyc_verified
set_user_two_factor_secret = user.set_two_factor_secret
enable_user_two_factor = user.enable_two_factor
disable_user_two_factor = user.disable_two_factor

87
app/crud/crud_wallet.py Normal file
View File

@ -0,0 +1,87 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.wallet import Wallet, WalletType
from app.schemas.wallet import WalletCreate, WalletUpdate
class CRUDWallet(CRUDBase[Wallet, WalletCreate, WalletUpdate]):
def get_by_user_and_type(
self, db: Session, *, user_id: int, wallet_type: WalletType
) -> Optional[Wallet]:
return db.query(Wallet).filter(
Wallet.user_id == user_id,
Wallet.wallet_type == wallet_type
).first()
def get_by_user(
self, db: Session, *, user_id: int
) -> List[Wallet]:
return db.query(Wallet).filter(Wallet.user_id == user_id).all()
def create_for_user(
self, db: Session, *, user_id: int, wallet_type: WalletType
) -> Wallet:
wallet = Wallet(
user_id=user_id,
wallet_type=wallet_type,
balance=0.0
)
db.add(wallet)
db.commit()
db.refresh(wallet)
return wallet
def update_balance(
self, db: Session, *, wallet_id: int, amount: float, add: bool = True
) -> Wallet:
wallet = self.get(db, id=wallet_id)
if not wallet:
return None
if add:
wallet.balance += amount
else:
wallet.balance -= amount
# Ensure balance doesn't go negative
if wallet.balance < 0:
wallet.balance = 0
db.add(wallet)
db.commit()
db.refresh(wallet)
return wallet
def transfer(
self, db: Session, *, user_id: int, from_type: WalletType, to_type: WalletType, amount: float
) -> tuple[Wallet, Wallet]:
from_wallet = self.get_by_user_and_type(db, user_id=user_id, wallet_type=from_type)
to_wallet = self.get_by_user_and_type(db, user_id=user_id, wallet_type=to_type)
if not from_wallet or not to_wallet:
return None, None
if from_wallet.balance < amount:
return None, None
# Update from wallet (subtract)
from_wallet = self.update_balance(db, wallet_id=from_wallet.id, amount=amount, add=False)
# Update to wallet (add)
to_wallet = self.update_balance(db, wallet_id=to_wallet.id, amount=amount, add=True)
return from_wallet, to_wallet
wallet = CRUDWallet(Wallet)
# Aliases for convenience
get_wallet = wallet.get
get_wallets_by_user = wallet.get_by_user
get_wallet_by_user_and_type = wallet.get_by_user_and_type
create_wallet_for_user = wallet.create_for_user
update_wallet_balance = wallet.update_balance
transfer_between_wallets = wallet.transfer

View File

@ -0,0 +1,73 @@
from typing import List, Optional
from sqlalchemy.orm import Session
from app.crud.base import CRUDBase
from app.models.withdrawal import Withdrawal, WithdrawalStatus
from app.schemas.withdrawal import WithdrawalCreate, WithdrawalUpdate
class CRUDWithdrawal(CRUDBase[Withdrawal, WithdrawalCreate, WithdrawalUpdate]):
def get_by_user(
self, db: Session, *, user_id: int, skip: int = 0, limit: int = 100
) -> List[Withdrawal]:
return (
db.query(Withdrawal)
.filter(Withdrawal.user_id == user_id)
.order_by(Withdrawal.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def get_by_status(
self, db: Session, *, status: str, skip: int = 0, limit: int = 100
) -> List[Withdrawal]:
return (
db.query(Withdrawal)
.filter(Withdrawal.status == status)
.order_by(Withdrawal.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def get_all_pending(
self, db: Session, *, skip: int = 0, limit: int = 100
) -> List[Withdrawal]:
return self.get_by_status(db, status=WithdrawalStatus.PENDING, skip=skip, limit=limit)
def approve(
self, db: Session, *, db_obj: Withdrawal, transaction_hash: str, admin_notes: Optional[str] = None
) -> Withdrawal:
update_data = {
"status": WithdrawalStatus.APPROVED,
"transaction_hash": transaction_hash
}
if admin_notes:
update_data["admin_notes"] = admin_notes
return super().update(db, db_obj=db_obj, obj_in=update_data)
def reject(
self, db: Session, *, db_obj: Withdrawal, admin_notes: str
) -> Withdrawal:
update_data = {
"status": WithdrawalStatus.REJECTED,
"admin_notes": admin_notes
}
return super().update(db, db_obj=db_obj, obj_in=update_data)
withdrawal = CRUDWithdrawal(Withdrawal)
# Aliases for convenience
get_withdrawal = withdrawal.get
create_withdrawal = withdrawal.create
get_withdrawals_by_user = withdrawal.get_by_user
get_withdrawals_by_status = withdrawal.get_by_status
get_all_pending_withdrawals = withdrawal.get_all_pending
approve_withdrawal = withdrawal.approve
reject_withdrawal = withdrawal.reject

0
app/db/__init__.py Normal file
View File

1
app/db/all_models.py Normal file
View File

@ -0,0 +1 @@
# Import all models here to ensure they are registered with SQLAlchemy

12
app/db/base.py Normal file
View File

@ -0,0 +1,12 @@
from app.db.base_class import Base # noqa
# Import all models here to ensure they are registered with SQLAlchemy
# This file will be used by alembic migrations
from app.models.user import User # noqa
from app.models.wallet import Wallet # noqa
from app.models.deposit import Deposit # noqa
from app.models.withdrawal import Withdrawal # noqa
from app.models.transaction import Transaction # noqa
from app.models.bot import Bot # noqa
from app.models.bot_purchase import BotPurchase # noqa
from app.models.kyc import KYC # noqa

20
app/db/base_class.py Normal file
View File

@ -0,0 +1,20 @@
from typing import Any
from sqlalchemy import Column, DateTime
from sqlalchemy.ext.declarative import as_declarative, declared_attr
from sqlalchemy.sql import func
@as_declarative()
class Base:
id: Any
__name__: str
# Generate __tablename__ automatically
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower()
# Common columns for all models
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

31
app/db/init_db.py Normal file
View File

@ -0,0 +1,31 @@
import logging
from sqlalchemy.orm import Session
from app.core.config import settings
from app.crud.crud_user import create_user, get_user_by_email
from app.schemas.user import UserCreate, UserRole
from app.db.base import Base
from app.db.session import engine
logger = logging.getLogger(__name__)
def init_db(db: Session) -> None:
# Create tables if they don't exist
Base.metadata.create_all(bind=engine)
# Create initial admin user if it doesn't exist
admin_user = get_user_by_email(db, email=settings.ADMIN_EMAIL)
if not admin_user:
user_in = UserCreate(
email=settings.ADMIN_EMAIL,
password=settings.ADMIN_PASSWORD,
is_active=True,
role=UserRole.ADMIN,
full_name="Admin User"
)
create_user(db, obj_in=user_in)
logger.info(f"Admin user {settings.ADMIN_EMAIL} created")
else:
logger.info(f"Admin user {settings.ADMIN_EMAIL} already exists")

23
app/db/session.py Normal file
View File

@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
# Create database directory if it doesn't exist
settings.DB_DIR.mkdir(parents=True, exist_ok=True)
SQLALCHEMY_DATABASE_URL = f"sqlite:///{settings.DB_DIR}/db.sqlite"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Dependency to get DB session
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

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

19
app/models/bot.py Normal file
View File

@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, Text
from sqlalchemy.orm import relationship
from app.db.base_class import Base
class Bot(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
roi_percentage = Column(Float, nullable=False) # Expected ROI percentage
duration_hours = Column(Integer, nullable=False) # Duration in hours
min_purchase_amount = Column(Float, nullable=False)
max_purchase_amount = Column(Float, nullable=False)
is_active = Column(Boolean, default=True)
image_path = Column(String, nullable=True)
# Relationships
purchases = relationship("BotPurchase", back_populates="bot", cascade="all, delete-orphan")

View File

@ -0,0 +1,28 @@
from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime
from sqlalchemy.orm import relationship
import enum
import datetime
from app.db.base_class import Base
class BotPurchaseStatus(str, enum.Enum):
RUNNING = "running"
COMPLETED = "completed"
CANCELLED = "cancelled"
class BotPurchase(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
bot_id = Column(Integer, ForeignKey("bot.id"), nullable=False)
amount = Column(Float, nullable=False)
expected_roi_amount = Column(Float, nullable=False)
start_time = Column(DateTime, default=datetime.datetime.utcnow)
end_time = Column(DateTime)
status = Column(String, default=BotPurchaseStatus.RUNNING)
# Relationships
user = relationship("User", back_populates="bot_purchases")
bot = relationship("Bot", back_populates="purchases")
transaction = relationship("Transaction", back_populates="bot_purchase", uselist=False)

25
app/models/deposit.py Normal file
View File

@ -0,0 +1,25 @@
from sqlalchemy import Column, Integer, String, Float, ForeignKey, Text
from sqlalchemy.orm import relationship
import enum
from app.db.base_class import Base
class DepositStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class Deposit(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
amount = Column(Float, nullable=False)
transaction_hash = Column(String, nullable=False)
proof_image_path = Column(String, nullable=True)
status = Column(String, default=DepositStatus.PENDING, nullable=False)
admin_notes = Column(Text, nullable=True)
# Relationships
user = relationship("User", back_populates="deposits")
transaction = relationship("Transaction", back_populates="deposit", uselist=False, cascade="all, delete-orphan")

26
app/models/kyc.py Normal file
View File

@ -0,0 +1,26 @@
from sqlalchemy import Column, Integer, String, Text, ForeignKey
from sqlalchemy.orm import relationship
import enum
from app.db.base_class import Base
class KYCStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class KYC(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), unique=True, nullable=False)
full_name = Column(String, nullable=False)
id_document_type = Column(String, nullable=False) # passport, id_card, driver_license
id_document_path = Column(String, nullable=False)
selfie_path = Column(String, nullable=False)
additional_document_path = Column(String, nullable=True)
status = Column(String, default=KYCStatus.PENDING)
rejection_reason = Column(Text, nullable=True)
# Relationships
user = relationship("User", back_populates="kyc")

36
app/models/transaction.py Normal file
View File

@ -0,0 +1,36 @@
from sqlalchemy import Column, Integer, String, Float, ForeignKey, Text
from sqlalchemy.orm import relationship
import enum
from app.db.base_class import Base
class TransactionType(str, enum.Enum):
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
TRANSFER = "transfer"
BOT_PURCHASE = "bot_purchase"
BOT_EARNING = "bot_earning"
ADMIN_ADJUSTMENT = "admin_adjustment"
class Transaction(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
wallet_id = Column(Integer, ForeignKey("wallet.id"), nullable=False)
deposit_id = Column(Integer, ForeignKey("deposit.id"), nullable=True)
withdrawal_id = Column(Integer, ForeignKey("withdrawal.id"), nullable=True)
bot_purchase_id = Column(Integer, ForeignKey("botpurchase.id"), nullable=True)
transaction_type = Column(String, nullable=False)
amount = Column(Float, nullable=False)
description = Column(Text, nullable=True)
related_transaction_id = Column(Integer, ForeignKey("transaction.id"), nullable=True)
# Relationships
user = relationship("User", back_populates="transactions")
wallet = relationship("Wallet", back_populates="transactions")
deposit = relationship("Deposit", back_populates="transaction", uselist=False)
withdrawal = relationship("Withdrawal", back_populates="transaction", uselist=False)
bot_purchase = relationship("BotPurchase", back_populates="transaction", uselist=False)
related_transaction = relationship("Transaction", remote_side=[id], uselist=False)

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

@ -0,0 +1,31 @@
from sqlalchemy import Boolean, Column, String, Integer
from sqlalchemy.orm import relationship
import enum
from app.db.base_class import Base
class UserRole(str, enum.Enum):
USER = "user"
ADMIN = "admin"
class User(Base):
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
full_name = Column(String)
hashed_password = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
is_verified = Column(Boolean, default=False)
is_kyc_verified = Column(Boolean, default=False)
role = Column(String, default=UserRole.USER)
two_factor_secret = Column(String, nullable=True)
is_two_factor_enabled = Column(Boolean, default=False)
# Relationships
wallets = relationship("Wallet", back_populates="user", cascade="all, delete-orphan")
deposits = relationship("Deposit", back_populates="user", cascade="all, delete-orphan")
withdrawals = relationship("Withdrawal", back_populates="user", cascade="all, delete-orphan")
transactions = relationship("Transaction", back_populates="user", cascade="all, delete-orphan")
bot_purchases = relationship("BotPurchase", back_populates="user", cascade="all, delete-orphan")
kyc = relationship("KYC", back_populates="user", uselist=False, cascade="all, delete-orphan")

21
app/models/wallet.py Normal file
View File

@ -0,0 +1,21 @@
from sqlalchemy import Column, Integer, String, Float, ForeignKey
from sqlalchemy.orm import relationship
import enum
from app.db.base_class import Base
class WalletType(str, enum.Enum):
SPOT = "spot"
TRADING = "trading"
class Wallet(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
wallet_type = Column(String, nullable=False)
balance = Column(Float, default=0.0, nullable=False)
# Relationships
user = relationship("User", back_populates="wallets")
transactions = relationship("Transaction", back_populates="wallet", cascade="all, delete-orphan")

26
app/models/withdrawal.py Normal file
View File

@ -0,0 +1,26 @@
from sqlalchemy import Column, Integer, String, Float, ForeignKey, Text
from sqlalchemy.orm import relationship
import enum
from app.db.base_class import Base
class WithdrawalStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class Withdrawal(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
amount = Column(Float, nullable=False)
fee = Column(Float, nullable=False, default=0.0)
wallet_address = Column(String, nullable=False)
status = Column(String, default=WithdrawalStatus.PENDING, nullable=False)
admin_notes = Column(Text, nullable=True)
transaction_hash = Column(String, nullable=True) # For when admin processes withdrawal
# Relationships
user = relationship("User", back_populates="withdrawals")
transaction = relationship("Transaction", back_populates="withdrawal", uselist=False, cascade="all, delete-orphan")

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

42
app/schemas/bot.py Normal file
View File

@ -0,0 +1,42 @@
from typing import Optional
from pydantic import BaseModel, Field
from datetime import datetime
class BotBase(BaseModel):
name: str
description: Optional[str] = None
roi_percentage: float = Field(..., gt=0)
duration_hours: int = Field(..., gt=0)
min_purchase_amount: float = Field(..., gt=0)
max_purchase_amount: float = Field(..., gt=0)
is_active: bool = True
class BotCreate(BotBase):
pass
class BotUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
roi_percentage: Optional[float] = Field(None, gt=0)
duration_hours: Optional[int] = Field(None, gt=0)
min_purchase_amount: Optional[float] = Field(None, gt=0)
max_purchase_amount: Optional[float] = Field(None, gt=0)
is_active: Optional[bool] = None
image_path: Optional[str] = None
class BotInDBBase(BotBase):
id: int
image_path: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
class Bot(BotInDBBase):
pass

View File

@ -0,0 +1,58 @@
from typing import Optional
from pydantic import BaseModel, Field
from datetime import datetime
import enum
# Import here to avoid circular imports
from app.schemas.bot import Bot
class BotPurchaseStatus(str, enum.Enum):
RUNNING = "running"
COMPLETED = "completed"
CANCELLED = "cancelled"
class BotPurchaseBase(BaseModel):
bot_id: int
amount: float = Field(..., gt=0)
expected_roi_amount: float
class BotPurchaseCreate(BotPurchaseBase):
user_id: int
start_time: datetime = datetime.utcnow()
end_time: datetime
status: BotPurchaseStatus = BotPurchaseStatus.RUNNING
class BotPurchaseUpdate(BaseModel):
status: Optional[BotPurchaseStatus] = None
class BotPurchaseInDBBase(BotPurchaseBase):
id: int
user_id: int
start_time: datetime
end_time: datetime
status: BotPurchaseStatus
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
class BotPurchase(BotPurchaseInDBBase):
pass
class BotPurchaseRequest(BaseModel):
amount: float = Field(..., gt=0)
class BotPurchaseWithBot(BotPurchase):
bot: Bot
class Config:
orm_mode = True

54
app/schemas/deposit.py Normal file
View File

@ -0,0 +1,54 @@
from typing import Optional
from pydantic import BaseModel, Field
from datetime import datetime
import enum
class DepositStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class DepositBase(BaseModel):
amount: float = Field(..., gt=0)
transaction_hash: str
admin_notes: Optional[str] = None
class DepositCreate(DepositBase):
user_id: int
class DepositUpdate(BaseModel):
status: Optional[DepositStatus] = None
admin_notes: Optional[str] = None
class DepositInDBBase(DepositBase):
id: int
user_id: int
status: DepositStatus
proof_image_path: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
class Deposit(DepositInDBBase):
pass
class DepositRequest(BaseModel):
amount: float = Field(..., gt=0)
transaction_hash: str
class DepositApprove(BaseModel):
admin_notes: Optional[str] = None
class DepositReject(BaseModel):
admin_notes: str

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

@ -0,0 +1,57 @@
from typing import Optional
from pydantic import BaseModel
from datetime import datetime
import enum
class KYCStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class IDDocumentType(str, enum.Enum):
PASSPORT = "passport"
ID_CARD = "id_card"
DRIVERS_LICENSE = "drivers_license"
class KYCBase(BaseModel):
full_name: str
id_document_type: IDDocumentType
id_document_path: str
selfie_path: str
additional_document_path: Optional[str] = None
rejection_reason: Optional[str] = None
class KYCCreate(KYCBase):
user_id: int
class KYCUpdate(BaseModel):
status: Optional[KYCStatus] = None
rejection_reason: Optional[str] = None
class KYCInDBBase(KYCBase):
id: int
user_id: int
status: KYCStatus
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
class KYC(KYCInDBBase):
pass
class KYCApprove(BaseModel):
pass
class KYCReject(BaseModel):
rejection_reason: str

View File

@ -0,0 +1,47 @@
from typing import Optional
from pydantic import BaseModel
from datetime import datetime
import enum
class TransactionType(str, enum.Enum):
DEPOSIT = "deposit"
WITHDRAWAL = "withdrawal"
TRANSFER = "transfer"
BOT_PURCHASE = "bot_purchase"
BOT_EARNING = "bot_earning"
ADMIN_ADJUSTMENT = "admin_adjustment"
class TransactionBase(BaseModel):
user_id: int
wallet_id: int
amount: float
transaction_type: TransactionType
description: Optional[str] = None
related_transaction_id: Optional[int] = None
deposit_id: Optional[int] = None
withdrawal_id: Optional[int] = None
bot_purchase_id: Optional[int] = None
class TransactionCreate(TransactionBase):
pass
class TransactionUpdate(BaseModel):
description: Optional[str] = None
related_transaction_id: Optional[int] = None
class TransactionInDBBase(TransactionBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
class Transaction(TransactionInDBBase):
pass

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

@ -0,0 +1,93 @@
from typing import Optional
from pydantic import BaseModel, EmailStr, Field
import enum
class UserRole(str, enum.Enum):
USER = "user"
ADMIN = "admin"
class UserBase(BaseModel):
email: EmailStr
full_name: Optional[str] = None
is_active: Optional[bool] = True
class UserCreate(UserBase):
password: str
role: Optional[UserRole] = UserRole.USER
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
full_name: Optional[str] = None
password: Optional[str] = None
is_active: Optional[bool] = None
class UserInDBBase(UserBase):
id: int
is_verified: bool
is_kyc_verified: bool
role: UserRole
is_two_factor_enabled: bool
class Config:
orm_mode = True
class User(UserInDBBase):
pass
class UserInDB(UserInDBBase):
hashed_password: str
class Token(BaseModel):
access_token: str
token_type: str
refresh_token: Optional[str] = None
requires_two_factor: Optional[bool] = False
class TokenPayload(BaseModel):
sub: str
exp: int
role: str
class RefreshToken(BaseModel):
refresh_token: str
class PasswordReset(BaseModel):
email: EmailStr
class PasswordResetConfirm(BaseModel):
token: str
new_password: str
class EmailVerification(BaseModel):
token: str
class TwoFactorSetup(BaseModel):
password: str
class TwoFactorVerify(BaseModel):
code: str = Field(..., min_length=6, max_length=6)
class TwoFactorLogin(BaseModel):
token: str
code: str = Field(..., min_length=6, max_length=6)
class TwoFactorDisable(BaseModel):
password: str
code: str = Field(..., min_length=6, max_length=6)

47
app/schemas/wallet.py Normal file
View File

@ -0,0 +1,47 @@
from typing import Optional
from pydantic import BaseModel, Field
from datetime import datetime
import enum
class WalletType(str, enum.Enum):
SPOT = "spot"
TRADING = "trading"
class WalletBase(BaseModel):
wallet_type: WalletType
balance: float = Field(..., ge=0)
class WalletCreate(WalletBase):
user_id: int
class WalletUpdate(BaseModel):
balance: Optional[float] = Field(None, ge=0)
class WalletInDBBase(WalletBase):
id: int
user_id: int
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
class Wallet(WalletInDBBase):
pass
class WalletWithBalance(BaseModel):
wallet_type: WalletType
balance: float
class WalletTransfer(BaseModel):
from_wallet_type: WalletType
to_wallet_type: WalletType
amount: float = Field(..., gt=0)

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

@ -0,0 +1,57 @@
from typing import Optional
from pydantic import BaseModel, Field
from datetime import datetime
import enum
class WithdrawalStatus(str, enum.Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class WithdrawalBase(BaseModel):
amount: float = Field(..., gt=0)
fee: float = Field(0.0, ge=0)
wallet_address: str
admin_notes: Optional[str] = None
class WithdrawalCreate(WithdrawalBase):
user_id: int
class WithdrawalUpdate(BaseModel):
status: Optional[WithdrawalStatus] = None
transaction_hash: Optional[str] = None
admin_notes: Optional[str] = None
class WithdrawalInDBBase(WithdrawalBase):
id: int
user_id: int
status: WithdrawalStatus
transaction_hash: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
orm_mode = True
class Withdrawal(WithdrawalInDBBase):
pass
class WithdrawalRequest(BaseModel):
amount: float = Field(..., gt=0)
wallet_address: str
class WithdrawalApprove(BaseModel):
transaction_hash: str
admin_notes: Optional[str] = None
class WithdrawalReject(BaseModel):
admin_notes: str

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

View File

@ -0,0 +1,144 @@
import logging
from sqlalchemy.orm import Session
from app import crud, schemas
from app.models.transaction import TransactionType
from app.models.wallet import WalletType
from app.core.email import send_bot_completion_notification
logger = logging.getLogger(__name__)
def process_completed_bot_purchases(db: Session) -> int:
"""
Process bot purchases that have reached their end time.
Returns the number of bot purchases processed.
"""
# Get bot purchases that are running but have passed their end time
completed_due = crud.bot_purchase.get_completed_due(db)
if not completed_due:
return 0
count = 0
for purchase in completed_due:
try:
# Get user and bot information
user = crud.user.get(db, id=purchase.user_id)
bot = crud.bot.get(db, id=purchase.bot_id)
if not user or not bot:
logger.error(
f"User or bot not found for bot purchase {purchase.id}. "
f"User ID: {purchase.user_id}, Bot ID: {purchase.bot_id}"
)
continue
# Get user's trading wallet
trading_wallet = crud.wallet.get_by_user_and_type(
db, user_id=user.id, wallet_type=WalletType.TRADING
)
if not trading_wallet:
logger.error(
f"Trading wallet not found for user {user.id} "
f"when processing bot purchase {purchase.id}"
)
continue
# Calculate the total amount to be credited (principal + ROI)
principal = purchase.amount
roi = purchase.expected_roi_amount
total = principal + roi
# Update trading wallet balance
crud.wallet.update_balance(db, wallet_id=trading_wallet.id, amount=total, add=True)
# Create transaction records
# 1. Return of principal
principal_transaction = crud.transaction.create(
db,
obj_in=schemas.TransactionCreate(
user_id=user.id,
wallet_id=trading_wallet.id,
amount=principal,
transaction_type=TransactionType.BOT_EARNING,
description=f"Bot {bot.name} - Return of principal",
bot_purchase_id=purchase.id,
),
)
# 2. ROI
roi_transaction = crud.transaction.create(
db,
obj_in=schemas.TransactionCreate(
user_id=user.id,
wallet_id=trading_wallet.id,
amount=roi,
transaction_type=TransactionType.BOT_EARNING,
description=f"Bot {bot.name} - ROI",
bot_purchase_id=purchase.id,
related_transaction_id=principal_transaction.id,
),
)
# Update the first transaction to reference the second
crud.transaction.update(
db,
db_obj=principal_transaction,
obj_in={"related_transaction_id": roi_transaction.id},
)
# Mark the bot purchase as completed
crud.bot_purchase.complete(db, db_obj=purchase)
# Send email notification
send_bot_completion_notification(
email_to=user.email,
bot_name=bot.name,
amount=principal,
roi=roi,
)
count += 1
except Exception as e:
logger.error(
f"Error processing bot purchase {purchase.id}: {str(e)}"
)
return count
def get_bot_simulation_stats(db: Session) -> dict:
"""
Get statistics about bot simulations.
"""
# Get counts by status
running_count = len(crud.bot_purchase.get_by_status(
db, status=schemas.BotPurchaseStatus.RUNNING
))
completed_count = len(crud.bot_purchase.get_by_status(
db, status=schemas.BotPurchaseStatus.COMPLETED
))
cancelled_count = len(crud.bot_purchase.get_by_status(
db, status=schemas.BotPurchaseStatus.CANCELLED
))
# Calculate total invested amount (running bots)
running_purchases = crud.bot_purchase.get_by_status(
db, status=schemas.BotPurchaseStatus.RUNNING
)
total_invested = sum(p.amount for p in running_purchases)
# Calculate total ROI generated (completed bots)
completed_purchases = crud.bot_purchase.get_by_status(
db, status=schemas.BotPurchaseStatus.COMPLETED
)
total_roi_generated = sum(p.expected_roi_amount for p in completed_purchases)
return {
"running_count": running_count,
"completed_count": completed_count,
"cancelled_count": cancelled_count,
"total_invested": total_invested,
"total_roi_generated": total_roi_generated,
}

129
app/services/file_upload.py Normal file
View File

@ -0,0 +1,129 @@
import os
import shutil
import uuid
from pathlib import Path
from typing import Optional
from fastapi import UploadFile, HTTPException, status
from app.core.config import settings
def validate_file_size(file: UploadFile, max_size: int = settings.MAX_UPLOAD_SIZE) -> None:
"""Validate the size of an uploaded file."""
# Move cursor to the end to get size, then back to the beginning
file.file.seek(0, os.SEEK_END)
file_size = file.file.tell()
file.file.seek(0)
if file_size > max_size:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File size too large. Maximum size is {max_size / (1024 * 1024):.1f} MB.",
)
def save_upload_file(
file: UploadFile,
destination_dir: Path,
allowed_extensions: Optional[list[str]] = None,
filename: Optional[str] = None,
) -> str:
"""Save an uploaded file to a destination directory."""
# Create directory if it doesn't exist
destination_dir.mkdir(parents=True, exist_ok=True)
# Validate file size
validate_file_size(file)
# Get file extension and validate if needed
if file.filename:
ext = file.filename.split(".")[-1].lower()
if allowed_extensions and ext not in allowed_extensions:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type not allowed. Allowed types: {', '.join(allowed_extensions)}",
)
else:
ext = "bin" # Default extension if no filename
# Generate a unique filename if not provided
if not filename:
filename = f"{uuid.uuid4()}.{ext}"
elif not filename.endswith(f".{ext}"):
filename = f"{filename}.{ext}"
# Full path to save the file
file_path = destination_dir / filename
# Save the file
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
return str(file_path)
def save_deposit_proof(user_id: int, file: UploadFile) -> str:
"""Save a deposit proof image."""
# Define allowed extensions for deposit proofs
allowed_extensions = ["jpg", "jpeg", "png", "pdf"]
# Define the destination directory
destination_dir = settings.DEPOSIT_PROOFS_DIR
# Generate a filename with user_id for organization
filename = f"deposit_proof_{user_id}_{uuid.uuid4()}"
# Save the file
file_path = save_upload_file(
file=file,
destination_dir=destination_dir,
allowed_extensions=allowed_extensions,
filename=filename,
)
return file_path
def save_kyc_document(user_id: int, document_type: str, file: UploadFile) -> str:
"""Save a KYC document."""
# Define allowed extensions for KYC documents
allowed_extensions = ["jpg", "jpeg", "png", "pdf"]
# Define the destination directory
destination_dir = settings.KYC_UPLOAD_DIR
# Generate a filename with user_id and document_type for organization
filename = f"kyc_{user_id}_{document_type}_{uuid.uuid4()}"
# Save the file
file_path = save_upload_file(
file=file,
destination_dir=destination_dir,
allowed_extensions=allowed_extensions,
filename=filename,
)
return file_path
def save_bot_image(bot_id: int, file: UploadFile) -> str:
"""Save a bot image."""
# Define allowed extensions for bot images
allowed_extensions = ["jpg", "jpeg", "png"]
# Define the destination directory
destination_dir = Path(settings.UPLOAD_DIR) / "bot_images"
# Generate a filename with bot_id for organization
filename = f"bot_{bot_id}_{uuid.uuid4()}"
# Save the file
file_path = save_upload_file(
file=file,
destination_dir=destination_dir,
allowed_extensions=allowed_extensions,
filename=filename,
)
return file_path

View File

@ -0,0 +1,44 @@
import pyotp
import qrcode
from io import BytesIO
import base64
from app.core.config import settings
def generate_totp_secret() -> str:
"""Generate a new TOTP secret."""
return pyotp.random_base32()
def get_totp_uri(secret: str, email: str) -> str:
"""Generate TOTP URI for QR code."""
return pyotp.totp.TOTP(secret).provisioning_uri(
name=email, issuer_name=settings.PROJECT_NAME
)
def generate_qr_code(secret: str, email: str) -> str:
"""Generate QR code as base64 string."""
totp_uri = get_totp_uri(secret, email)
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(totp_uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffered = BytesIO()
img.save(buffered)
img_str = base64.b64encode(buffered.getvalue()).decode()
return f"data:image/png;base64,{img_str}"
def verify_totp(secret: str, code: str) -> bool:
"""Verify TOTP code."""
totp = pyotp.TOTP(secret)
return totp.verify(code)

76
main.py Normal file
View File

@ -0,0 +1,76 @@
import uvicorn
import asyncio
from datetime import datetime
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from app.api.v1.api import api_router
from app.core.config import settings
from app.db.session import get_db
from app.core.background_tasks import task_manager
app = FastAPI(
title=settings.PROJECT_NAME,
description=settings.PROJECT_DESCRIPTION,
version=settings.VERSION,
openapi_url="/openapi.json",
docs_url="/docs",
redoc_url="/redoc",
)
# Set up CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.BACKEND_CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API router
app.include_router(api_router, prefix=settings.API_V1_STR)
# Root endpoint
@app.get("/")
async def root():
return {
"name": settings.PROJECT_NAME,
"version": settings.VERSION,
"documentation": "/docs",
"health": "/health"
}
# Health check endpoint
@app.get("/health", tags=["health"])
async def health_check(db: Session = Depends(get_db)):
# Check if database is available
try:
# Execute a simple query
db.execute("SELECT 1")
db_status = "connected"
except Exception as e:
db_status = f"error: {str(e)}"
return {
"status": "ok",
"version": settings.VERSION,
"timestamp": datetime.utcnow().isoformat(),
"database": db_status,
"environment": "production" if not settings.DEBUG else "development",
}
# Startup event
@app.on_event("startup")
async def startup_event():
# Start background tasks
asyncio.create_task(task_manager.start())
# Shutdown event
@app.on_event("shutdown")
async def shutdown_event():
# Stop background tasks
task_manager.stop()
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

0
migrations/__init__.py Normal file
View File

88
migrations/env.py Normal file
View File

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

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

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

View File

View File

@ -0,0 +1,197 @@
"""Initial migration
Revision ID: 001
Revises:
Create Date: 2023-06-13
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Create user table
op.create_table(
'user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('full_name', sa.String(), nullable=True),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('is_active', sa.Boolean(), default=True),
sa.Column('is_verified', sa.Boolean(), default=False),
sa.Column('is_kyc_verified', sa.Boolean(), default=False),
sa.Column('role', sa.String(), default='user'),
sa.Column('two_factor_secret', sa.String(), nullable=True),
sa.Column('is_two_factor_enabled', sa.Boolean(), default=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False)
# Create wallet table
op.create_table(
'wallet',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('wallet_type', sa.String(), nullable=False),
sa.Column('balance', sa.Float(), default=0.0, nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_wallet_id'), 'wallet', ['id'], unique=False)
# Create deposit table
op.create_table(
'deposit',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('transaction_hash', sa.String(), nullable=False),
sa.Column('proof_image_path', sa.String(), nullable=True),
sa.Column('status', sa.String(), default='pending', nullable=False),
sa.Column('admin_notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_deposit_id'), 'deposit', ['id'], unique=False)
# Create withdrawal table
op.create_table(
'withdrawal',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('fee', sa.Float(), default=0.0, nullable=False),
sa.Column('wallet_address', sa.String(), nullable=False),
sa.Column('status', sa.String(), default='pending', nullable=False),
sa.Column('admin_notes', sa.Text(), nullable=True),
sa.Column('transaction_hash', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_withdrawal_id'), 'withdrawal', ['id'], unique=False)
# Create bot table
op.create_table(
'bot',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('roi_percentage', sa.Float(), nullable=False),
sa.Column('duration_hours', sa.Integer(), nullable=False),
sa.Column('min_purchase_amount', sa.Float(), nullable=False),
sa.Column('max_purchase_amount', sa.Float(), nullable=False),
sa.Column('is_active', sa.Boolean(), default=True),
sa.Column('image_path', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_bot_id'), 'bot', ['id'], unique=False)
# Create bot purchase table
op.create_table(
'botpurchase',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('bot_id', sa.Integer(), nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('expected_roi_amount', sa.Float(), nullable=False),
sa.Column('start_time', sa.DateTime(), default=sa.func.now()),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('status', sa.String(), default='running'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['bot_id'], ['bot.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_botpurchase_id'), 'botpurchase', ['id'], unique=False)
# Create KYC table
op.create_table(
'kyc',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('full_name', sa.String(), nullable=False),
sa.Column('id_document_type', sa.String(), nullable=False),
sa.Column('id_document_path', sa.String(), nullable=False),
sa.Column('selfie_path', sa.String(), nullable=False),
sa.Column('additional_document_path', sa.String(), nullable=True),
sa.Column('status', sa.String(), default='pending'),
sa.Column('rejection_reason', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id')
)
op.create_index(op.f('ix_kyc_id'), 'kyc', ['id'], unique=False)
# Create transaction table - must be last due to self-referential relationship
op.create_table(
'transaction',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('wallet_id', sa.Integer(), nullable=False),
sa.Column('deposit_id', sa.Integer(), nullable=True),
sa.Column('withdrawal_id', sa.Integer(), nullable=True),
sa.Column('bot_purchase_id', sa.Integer(), nullable=True),
sa.Column('transaction_type', sa.String(), nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('related_transaction_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['bot_purchase_id'], ['botpurchase.id'], ),
sa.ForeignKeyConstraint(['deposit_id'], ['deposit.id'], ),
sa.ForeignKeyConstraint(['related_transaction_id'], ['transaction.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['wallet_id'], ['wallet.id'], ),
sa.ForeignKeyConstraint(['withdrawal_id'], ['withdrawal.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_transaction_id'), 'transaction', ['id'], unique=False)
def downgrade():
# Drop tables in reverse order of creation
op.drop_index(op.f('ix_transaction_id'), table_name='transaction')
op.drop_table('transaction')
op.drop_index(op.f('ix_kyc_id'), table_name='kyc')
op.drop_table('kyc')
op.drop_index(op.f('ix_botpurchase_id'), table_name='botpurchase')
op.drop_table('botpurchase')
op.drop_index(op.f('ix_bot_id'), table_name='bot')
op.drop_table('bot')
op.drop_index(op.f('ix_withdrawal_id'), table_name='withdrawal')
op.drop_table('withdrawal')
op.drop_index(op.f('ix_deposit_id'), table_name='deposit')
op.drop_table('deposit')
op.drop_index(op.f('ix_wallet_id'), table_name='wallet')
op.drop_table('wallet')
op.drop_index(op.f('ix_user_id'), table_name='user')
op.drop_index(op.f('ix_user_email'), table_name='user')
op.drop_table('user')

19
requirements.txt Normal file
View File

@ -0,0 +1,19 @@
fastapi>=0.95.0
uvicorn>=0.21.1
sqlalchemy>=2.0.0
alembic>=1.10.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
pydantic>=2.0.0
pydantic-settings>=2.0.0
python-multipart>=0.0.6
email-validator>=2.0.0
pyotp>=2.8.0
pillow>=10.0.0
python-dotenv>=1.0.0
emails>=0.6
qrcode>=7.4.2
jinja2>=3.1.2
ruff>=0.0.292
httpx>=0.24.1
pytest>=7.3.1