From 8898d97d8ab55388e21e1cecc87ba0536aa81834 Mon Sep 17 00:00:00 2001 From: Automated Action Date: Fri, 13 Jun 2025 11:58:36 +0000 Subject: [PATCH] Implement Deft Trade DeFi Trading Simulation Platform Backend --- README.md | 213 ++++++++++++- alembic.ini | 85 +++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/deps.py | 76 +++++ app/api/v1/__init__.py | 0 app/api/v1/api.py | 13 + app/api/v1/endpoints/__init__.py | 0 app/api/v1/endpoints/admin.py | 286 +++++++++++++++++ app/api/v1/endpoints/auth.py | 384 +++++++++++++++++++++++ app/api/v1/endpoints/bots.py | 309 ++++++++++++++++++ app/api/v1/endpoints/deposits.py | 207 ++++++++++++ app/api/v1/endpoints/kyc.py | 201 ++++++++++++ app/api/v1/endpoints/wallets.py | 145 +++++++++ app/api/v1/endpoints/withdrawals.py | 249 +++++++++++++++ app/core/__init__.py | 0 app/core/background_tasks.py | 94 ++++++ app/core/config.py | 74 +++++ app/core/email.py | 262 ++++++++++++++++ app/core/security.py | 70 +++++ app/crud/__init__.py | 0 app/crud/base.py | 66 ++++ app/crud/crud_bot.py | 43 +++ app/crud/crud_bot_purchase.py | 89 ++++++ app/crud/crud_deposit.py | 70 +++++ app/crud/crud_kyc.py | 59 ++++ app/crud/crud_transaction.py | 69 ++++ app/crud/crud_user.py | 114 +++++++ app/crud/crud_wallet.py | 87 +++++ app/crud/crud_withdrawal.py | 73 +++++ app/db/__init__.py | 0 app/db/all_models.py | 1 + app/db/base.py | 12 + app/db/base_class.py | 20 ++ app/db/init_db.py | 31 ++ app/db/session.py | 23 ++ app/models/__init__.py | 0 app/models/bot.py | 19 ++ app/models/bot_purchase.py | 28 ++ app/models/deposit.py | 25 ++ app/models/kyc.py | 26 ++ app/models/transaction.py | 36 +++ app/models/user.py | 31 ++ app/models/wallet.py | 21 ++ app/models/withdrawal.py | 26 ++ app/schemas/__init__.py | 0 app/schemas/bot.py | 42 +++ app/schemas/bot_purchase.py | 58 ++++ app/schemas/deposit.py | 54 ++++ app/schemas/kyc.py | 57 ++++ app/schemas/transaction.py | 47 +++ app/schemas/user.py | 93 ++++++ app/schemas/wallet.py | 47 +++ app/schemas/withdrawal.py | 57 ++++ app/services/__init__.py | 0 app/services/bot_simulation.py | 144 +++++++++ app/services/file_upload.py | 129 ++++++++ app/services/two_factor.py | 44 +++ main.py | 76 +++++ migrations/__init__.py | 0 migrations/env.py | 88 ++++++ migrations/script.py.mako | 24 ++ migrations/versions/__init__.py | 0 migrations/versions/initial_migration.py | 197 ++++++++++++ requirements.txt | 19 ++ 65 files changed, 4811 insertions(+), 2 deletions(-) create mode 100644 alembic.ini create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/deps.py create mode 100644 app/api/v1/__init__.py create mode 100644 app/api/v1/api.py create mode 100644 app/api/v1/endpoints/__init__.py create mode 100644 app/api/v1/endpoints/admin.py create mode 100644 app/api/v1/endpoints/auth.py create mode 100644 app/api/v1/endpoints/bots.py create mode 100644 app/api/v1/endpoints/deposits.py create mode 100644 app/api/v1/endpoints/kyc.py create mode 100644 app/api/v1/endpoints/wallets.py create mode 100644 app/api/v1/endpoints/withdrawals.py create mode 100644 app/core/__init__.py create mode 100644 app/core/background_tasks.py create mode 100644 app/core/config.py create mode 100644 app/core/email.py create mode 100644 app/core/security.py create mode 100644 app/crud/__init__.py create mode 100644 app/crud/base.py create mode 100644 app/crud/crud_bot.py create mode 100644 app/crud/crud_bot_purchase.py create mode 100644 app/crud/crud_deposit.py create mode 100644 app/crud/crud_kyc.py create mode 100644 app/crud/crud_transaction.py create mode 100644 app/crud/crud_user.py create mode 100644 app/crud/crud_wallet.py create mode 100644 app/crud/crud_withdrawal.py create mode 100644 app/db/__init__.py create mode 100644 app/db/all_models.py create mode 100644 app/db/base.py create mode 100644 app/db/base_class.py create mode 100644 app/db/init_db.py create mode 100644 app/db/session.py create mode 100644 app/models/__init__.py create mode 100644 app/models/bot.py create mode 100644 app/models/bot_purchase.py create mode 100644 app/models/deposit.py create mode 100644 app/models/kyc.py create mode 100644 app/models/transaction.py create mode 100644 app/models/user.py create mode 100644 app/models/wallet.py create mode 100644 app/models/withdrawal.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/bot.py create mode 100644 app/schemas/bot_purchase.py create mode 100644 app/schemas/deposit.py create mode 100644 app/schemas/kyc.py create mode 100644 app/schemas/transaction.py create mode 100644 app/schemas/user.py create mode 100644 app/schemas/wallet.py create mode 100644 app/schemas/withdrawal.py create mode 100644 app/services/__init__.py create mode 100644 app/services/bot_simulation.py create mode 100644 app/services/file_upload.py create mode 100644 app/services/two_factor.py create mode 100644 main.py create mode 100644 migrations/__init__.py create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/__init__.py create mode 100644 migrations/versions/initial_migration.py create mode 100644 requirements.txt diff --git a/README.md b/README.md index e8acfba..fe405a7 100644 --- a/README.md +++ b/README.md @@ -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 + 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. \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..db63f43 --- /dev/null +++ b/alembic.ini @@ -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 \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/deps.py b/app/api/deps.py new file mode 100644 index 0000000..d5b45d2 --- /dev/null +++ b/app/api/deps.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/api.py b/app/api/v1/api.py new file mode 100644 index 0000000..e239344 --- /dev/null +++ b/app/api/v1/api.py @@ -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"]) \ No newline at end of file diff --git a/app/api/v1/endpoints/__init__.py b/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/endpoints/admin.py b/app/api/v1/endpoints/admin.py new file mode 100644 index 0000000..2085ba0 --- /dev/null +++ b/app/api/v1/endpoints/admin.py @@ -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, + }, + } \ No newline at end of file diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..eb303af --- /dev/null +++ b/app/api/v1/endpoints/auth.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/endpoints/bots.py b/app/api/v1/endpoints/bots.py new file mode 100644 index 0000000..55d321d --- /dev/null +++ b/app/api/v1/endpoints/bots.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/endpoints/deposits.py b/app/api/v1/endpoints/deposits.py new file mode 100644 index 0000000..e4c2a96 --- /dev/null +++ b/app/api/v1/endpoints/deposits.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/endpoints/kyc.py b/app/api/v1/endpoints/kyc.py new file mode 100644 index 0000000..d0c58e2 --- /dev/null +++ b/app/api/v1/endpoints/kyc.py @@ -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 \ No newline at end of file diff --git a/app/api/v1/endpoints/wallets.py b/app/api/v1/endpoints/wallets.py new file mode 100644 index 0000000..1349c4a --- /dev/null +++ b/app/api/v1/endpoints/wallets.py @@ -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, + }, + } \ No newline at end of file diff --git a/app/api/v1/endpoints/withdrawals.py b/app/api/v1/endpoints/withdrawals.py new file mode 100644 index 0000000..31a6400 --- /dev/null +++ b/app/api/v1/endpoints/withdrawals.py @@ -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 \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/background_tasks.py b/app/core/background_tasks.py new file mode 100644 index 0000000..2b9a6d8 --- /dev/null +++ b/app/core/background_tasks.py @@ -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, +) \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..ffca67a --- /dev/null +++ b/app/core/config.py @@ -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() \ No newline at end of file diff --git a/app/core/email.py b/app/core/email.py new file mode 100644 index 0000000..580db67 --- /dev/null +++ b/app/core/email.py @@ -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 = """ + + +

Hi,

+

This is a test email from {project_name}.

+ + + """ + + 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 = """ + + +

Hi,

+

Thanks for signing up for {project_name}.

+

Please verify your email address by clicking on the link below:

+

{verification_url}

+

The link is valid for 24 hours.

+ + + """ + + 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 = """ + + +

Hi,

+

You have requested to reset your password for {project_name}.

+

Please click the link below to reset your password:

+

{reset_url}

+

The link is valid for 24 hours.

+

If you didn't request a password reset, please ignore this email.

+ + + """ + + 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 = """ + + +

Hi,

+

Your deposit of {amount} USDT has been confirmed and added to your account.

+

Transaction ID: {transaction_id}

+

Thank you for using {project_name}!

+ + + """ + + 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 = """ + + +

Hi,

+

Your withdrawal of {amount} USDT has been processed.

+

Transaction ID: {transaction_id}

+

Thank you for using {project_name}!

+ + + """ + + 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 = """ + + +

Hi,

+

Your purchase of the {bot_name} bot for {amount} USDT has been confirmed.

+

Expected ROI: {expected_roi} USDT

+

End Date: {end_date}

+

Thank you for using {project_name}!

+ + + """ + + 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 = """ + + +

Hi,

+

Your {bot_name} bot has completed its trading cycle.

+

Principal: {amount} USDT

+

ROI: {roi} USDT

+

Total: {total} USDT

+

The funds have been credited to your Trading Wallet.

+

Thank you for using {project_name}!

+ + + """ + + 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 = """ + + +

Hi,

+

Your KYC verification has been {status}.

+ {reason_html} +

Thank you for using {project_name}!

+ + + """ + + reason_html = f"

Reason: {reason}

" 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, + }, + ) \ No newline at end of file diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..b21ff8f --- /dev/null +++ b/app/core/security.py @@ -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 \ No newline at end of file diff --git a/app/crud/__init__.py b/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/crud/base.py b/app/crud/base.py new file mode 100644 index 0000000..43040ef --- /dev/null +++ b/app/crud/base.py @@ -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 \ No newline at end of file diff --git a/app/crud/crud_bot.py b/app/crud/crud_bot.py new file mode 100644 index 0000000..51da72d --- /dev/null +++ b/app/crud/crud_bot.py @@ -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 \ No newline at end of file diff --git a/app/crud/crud_bot_purchase.py b/app/crud/crud_bot_purchase.py new file mode 100644 index 0000000..6119b10 --- /dev/null +++ b/app/crud/crud_bot_purchase.py @@ -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 \ No newline at end of file diff --git a/app/crud/crud_deposit.py b/app/crud/crud_deposit.py new file mode 100644 index 0000000..000a206 --- /dev/null +++ b/app/crud/crud_deposit.py @@ -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 \ No newline at end of file diff --git a/app/crud/crud_kyc.py b/app/crud/crud_kyc.py new file mode 100644 index 0000000..81bb5bb --- /dev/null +++ b/app/crud/crud_kyc.py @@ -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 \ No newline at end of file diff --git a/app/crud/crud_transaction.py b/app/crud/crud_transaction.py new file mode 100644 index 0000000..c47e19a --- /dev/null +++ b/app/crud/crud_transaction.py @@ -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 \ No newline at end of file diff --git a/app/crud/crud_user.py b/app/crud/crud_user.py new file mode 100644 index 0000000..f110387 --- /dev/null +++ b/app/crud/crud_user.py @@ -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 \ No newline at end of file diff --git a/app/crud/crud_wallet.py b/app/crud/crud_wallet.py new file mode 100644 index 0000000..7fe9245 --- /dev/null +++ b/app/crud/crud_wallet.py @@ -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 \ No newline at end of file diff --git a/app/crud/crud_withdrawal.py b/app/crud/crud_withdrawal.py new file mode 100644 index 0000000..9744fd7 --- /dev/null +++ b/app/crud/crud_withdrawal.py @@ -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 \ No newline at end of file diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/all_models.py b/app/db/all_models.py new file mode 100644 index 0000000..2a6ec75 --- /dev/null +++ b/app/db/all_models.py @@ -0,0 +1 @@ +# Import all models here to ensure they are registered with SQLAlchemy diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..f24529a --- /dev/null +++ b/app/db/base.py @@ -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 \ No newline at end of file diff --git a/app/db/base_class.py b/app/db/base_class.py new file mode 100644 index 0000000..532089b --- /dev/null +++ b/app/db/base_class.py @@ -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) \ No newline at end of file diff --git a/app/db/init_db.py b/app/db/init_db.py new file mode 100644 index 0000000..eb33956 --- /dev/null +++ b/app/db/init_db.py @@ -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") \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..224e0c9 --- /dev/null +++ b/app/db/session.py @@ -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() \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/bot.py b/app/models/bot.py new file mode 100644 index 0000000..1e3026b --- /dev/null +++ b/app/models/bot.py @@ -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") \ No newline at end of file diff --git a/app/models/bot_purchase.py b/app/models/bot_purchase.py new file mode 100644 index 0000000..c1248ef --- /dev/null +++ b/app/models/bot_purchase.py @@ -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) \ No newline at end of file diff --git a/app/models/deposit.py b/app/models/deposit.py new file mode 100644 index 0000000..a11304b --- /dev/null +++ b/app/models/deposit.py @@ -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") \ No newline at end of file diff --git a/app/models/kyc.py b/app/models/kyc.py new file mode 100644 index 0000000..5e63908 --- /dev/null +++ b/app/models/kyc.py @@ -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") \ No newline at end of file diff --git a/app/models/transaction.py b/app/models/transaction.py new file mode 100644 index 0000000..daba261 --- /dev/null +++ b/app/models/transaction.py @@ -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) \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..bd7e8d1 --- /dev/null +++ b/app/models/user.py @@ -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") \ No newline at end of file diff --git a/app/models/wallet.py b/app/models/wallet.py new file mode 100644 index 0000000..8fea173 --- /dev/null +++ b/app/models/wallet.py @@ -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") \ No newline at end of file diff --git a/app/models/withdrawal.py b/app/models/withdrawal.py new file mode 100644 index 0000000..eab258b --- /dev/null +++ b/app/models/withdrawal.py @@ -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") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/schemas/bot.py b/app/schemas/bot.py new file mode 100644 index 0000000..f80d25f --- /dev/null +++ b/app/schemas/bot.py @@ -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 \ No newline at end of file diff --git a/app/schemas/bot_purchase.py b/app/schemas/bot_purchase.py new file mode 100644 index 0000000..1da522f --- /dev/null +++ b/app/schemas/bot_purchase.py @@ -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 \ No newline at end of file diff --git a/app/schemas/deposit.py b/app/schemas/deposit.py new file mode 100644 index 0000000..a401974 --- /dev/null +++ b/app/schemas/deposit.py @@ -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 \ No newline at end of file diff --git a/app/schemas/kyc.py b/app/schemas/kyc.py new file mode 100644 index 0000000..5d9e103 --- /dev/null +++ b/app/schemas/kyc.py @@ -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 \ No newline at end of file diff --git a/app/schemas/transaction.py b/app/schemas/transaction.py new file mode 100644 index 0000000..e19f5cc --- /dev/null +++ b/app/schemas/transaction.py @@ -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 \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..dacccab --- /dev/null +++ b/app/schemas/user.py @@ -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) \ No newline at end of file diff --git a/app/schemas/wallet.py b/app/schemas/wallet.py new file mode 100644 index 0000000..8a22492 --- /dev/null +++ b/app/schemas/wallet.py @@ -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) \ No newline at end of file diff --git a/app/schemas/withdrawal.py b/app/schemas/withdrawal.py new file mode 100644 index 0000000..ad250d8 --- /dev/null +++ b/app/schemas/withdrawal.py @@ -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 \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/bot_simulation.py b/app/services/bot_simulation.py new file mode 100644 index 0000000..47ff9eb --- /dev/null +++ b/app/services/bot_simulation.py @@ -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, + } \ No newline at end of file diff --git a/app/services/file_upload.py b/app/services/file_upload.py new file mode 100644 index 0000000..b8dbb5f --- /dev/null +++ b/app/services/file_upload.py @@ -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 \ No newline at end of file diff --git a/app/services/two_factor.py b/app/services/two_factor.py new file mode 100644 index 0000000..84bc221 --- /dev/null +++ b/app/services/two_factor.py @@ -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) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..3de24f7 --- /dev/null +++ b/main.py @@ -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) \ No newline at end of file diff --git a/migrations/__init__.py b/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..31d7a69 --- /dev/null +++ b/migrations/env.py @@ -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() \ No newline at end of file diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1e4564e --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/migrations/versions/__init__.py b/migrations/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/migrations/versions/initial_migration.py b/migrations/versions/initial_migration.py new file mode 100644 index 0000000..f449c0b --- /dev/null +++ b/migrations/versions/initial_migration.py @@ -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') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9f29e33 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file