Initial commit: WealthWise financial analytics platform
This commit is contained in:
56
backend/.env.example
Normal file
56
backend/.env.example
Normal file
@@ -0,0 +1,56 @@
|
||||
# ==========================================
|
||||
# WealthWise Backend Environment Variables
|
||||
# ==========================================
|
||||
# Copy this file to .env and fill in your actual values
|
||||
# DO NOT commit .env to version control!
|
||||
|
||||
# ==========================================
|
||||
# Application Configuration
|
||||
# ==========================================
|
||||
PROJECT_NAME=WealthWise
|
||||
API_V1_STR=/api/v1
|
||||
VERSION=1.0.0
|
||||
DEBUG=False
|
||||
|
||||
# ==========================================
|
||||
# Database Configuration (Self-Hosted PostgreSQL)
|
||||
# ==========================================
|
||||
# For Docker Compose:
|
||||
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/wealthwise
|
||||
|
||||
# For production (update with your actual values):
|
||||
# DATABASE_URL=postgresql+asyncpg://user:password@your-db-host:5432/wealthwise
|
||||
|
||||
# Database Pool Configuration
|
||||
DB_POOL_SIZE=20
|
||||
DB_MAX_OVERFLOW=10
|
||||
DB_POOL_PRE_PING=True
|
||||
DB_ECHO=False
|
||||
|
||||
# ==========================================
|
||||
# Security & Authentication
|
||||
# ==========================================
|
||||
# CRITICAL: Generate a strong secret key for JWT signing in production!
|
||||
# Use: openssl rand -hex 32
|
||||
SECRET_KEY=your-super-secret-key-change-this-in-production
|
||||
|
||||
# JWT Configuration
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# ==========================================
|
||||
# CORS Configuration
|
||||
# ==========================================
|
||||
# Comma-separated list of allowed origins
|
||||
# For development, include your frontend dev server
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173
|
||||
|
||||
# ==========================================
|
||||
# Logging
|
||||
# ==========================================
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# ==========================================
|
||||
# Optional: Monitoring (Future)
|
||||
# ==========================================
|
||||
# SENTRY_DSN=
|
||||
106
backend/.gitignore
vendored
Normal file
106
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,106 @@
|
||||
# ==========================================
|
||||
# Python / Poetry
|
||||
# ==========================================
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# Distribution / packaging
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Unit test / coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# ==========================================
|
||||
# Environment Variables
|
||||
# ==========================================
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.production
|
||||
.env.staging
|
||||
|
||||
# ==========================================
|
||||
# IDEs and Editors
|
||||
# ==========================================
|
||||
# VS Code
|
||||
.vscode/
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# PyCharm / IntelliJ
|
||||
.idea/
|
||||
*.iml
|
||||
*.iws
|
||||
*.ipr
|
||||
|
||||
# Sublime Text
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# Emacs
|
||||
*~
|
||||
\#*\#
|
||||
|
||||
# Vim
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# ==========================================
|
||||
# Logs and Databases
|
||||
# ==========================================
|
||||
*.log
|
||||
*.sql
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# ==========================================
|
||||
# Application Specific
|
||||
# ==========================================
|
||||
# Local development files
|
||||
local_settings.py
|
||||
|
||||
# Alembic migrations (if using)
|
||||
# Uncomment if you want to ignore migrations
|
||||
# alembic/versions/*
|
||||
# !alembic/versions/.gitkeep
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
164
backend/ALEMBIC.md
Normal file
164
backend/ALEMBIC.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# Database Migrations with Alembic
|
||||
|
||||
This guide explains how to set up and run database migrations using Alembic.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Database must be running (use Docker Compose)
|
||||
2. Alembic must be installed: `poetry install` (already included in pyproject.toml)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start the Database
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
docker-compose up -d db
|
||||
```
|
||||
|
||||
### 2. Initialize Alembic (First Time Only)
|
||||
|
||||
```bash
|
||||
# Install alembic if not already installed
|
||||
poetry add alembic
|
||||
|
||||
# Initialize alembic
|
||||
poetry run alembic init alembic
|
||||
```
|
||||
|
||||
### 3. Configure Alembic
|
||||
|
||||
Update `alembic.ini`:
|
||||
```ini
|
||||
sqlalchemy.url = postgresql://postgres:postgres@localhost:5432/wealthwise
|
||||
```
|
||||
|
||||
Update `alembic/env.py`:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
|
||||
|
||||
# Import your models
|
||||
from app.models import User, Portfolio, Transaction
|
||||
from app.models.base import BaseModel
|
||||
|
||||
# Set target metadata
|
||||
target_metadata = BaseModel.metadata
|
||||
|
||||
def do_run_migrations(connection):
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
async def run_migrations_online():
|
||||
"""Run migrations in 'online' mode with async engine."""
|
||||
configuration = config.get_section(config.config_ini_section)
|
||||
# Convert sync URL to async URL
|
||||
url = configuration['sqlalchemy.url'].replace(
|
||||
'postgresql://', 'postgresql+asyncpg://'
|
||||
)
|
||||
connectable = create_async_engine(url)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
asyncio.run(run_migrations_online())
|
||||
```
|
||||
|
||||
### 4. Create Initial Migration
|
||||
|
||||
```bash
|
||||
# Generate migration script
|
||||
poetry run alembic revision --autogenerate -m "Initial migration with user, portfolio, transaction"
|
||||
|
||||
# Apply migration
|
||||
poetry run alembic upgrade head
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Create a New Migration
|
||||
|
||||
```bash
|
||||
# Auto-generate from model changes
|
||||
poetry run alembic revision --autogenerate -m "Add new field to user"
|
||||
|
||||
# Create empty migration
|
||||
poetry run alembic revision -m "Manual migration"
|
||||
```
|
||||
|
||||
### Apply Migrations
|
||||
|
||||
```bash
|
||||
# Upgrade to latest
|
||||
poetry run alembic upgrade head
|
||||
|
||||
# Upgrade specific number of revisions
|
||||
poetry run alembic upgrade +2
|
||||
|
||||
# Upgrade to specific revision
|
||||
poetry run alembic upgrade abc123
|
||||
```
|
||||
|
||||
### Rollback Migrations
|
||||
|
||||
```bash
|
||||
# Downgrade one revision
|
||||
poetry run alembic downgrade -1
|
||||
|
||||
# Downgrade to specific revision
|
||||
poetry run alembic downgrade abc123
|
||||
|
||||
# Downgrade all the way
|
||||
poetry run alembic downgrade base
|
||||
```
|
||||
|
||||
### View Migration Status
|
||||
|
||||
```bash
|
||||
# Current revision
|
||||
poetry run alembic current
|
||||
|
||||
# History
|
||||
poetry run alembic history --verbose
|
||||
```
|
||||
|
||||
## Migration Best Practices
|
||||
|
||||
1. **Always review auto-generated migrations** before applying them
|
||||
2. **Test migrations** on a copy of production data before running in production
|
||||
3. **Never modify** existing migration files after they've been applied
|
||||
4. **Use descriptive names** for migration messages
|
||||
5. **One logical change per migration** (don't bundle unrelated changes)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Async Issues
|
||||
|
||||
If you encounter async-related errors, ensure you're using `create_async_engine` and `AsyncEngine` in `env.py`.
|
||||
|
||||
### Import Errors
|
||||
|
||||
Make sure all models are imported in `env.py`:
|
||||
```python
|
||||
from app.models import User, Portfolio, Transaction
|
||||
```
|
||||
|
||||
### Connection Issues
|
||||
|
||||
Ensure the database is running and accessible:
|
||||
```bash
|
||||
docker-compose ps
|
||||
# or
|
||||
pg_isready -h localhost -p 5432 -U postgres
|
||||
```
|
||||
71
backend/Dockerfile
Normal file
71
backend/Dockerfile
Normal file
@@ -0,0 +1,71 @@
|
||||
# Multi-stage Dockerfile for WealthWise Backend
|
||||
# Stage 1: Builder - Install dependencies and export requirements
|
||||
# Stage 2: Runtime - Slim production image
|
||||
|
||||
# ==========================================
|
||||
# STAGE 1: Builder
|
||||
# ==========================================
|
||||
FROM python:3.11-slim as builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install system dependencies for building Python packages
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Poetry
|
||||
RUN pip install --no-cache-dir poetry==1.7.1
|
||||
|
||||
# Copy Poetry configuration
|
||||
COPY pyproject.toml ./
|
||||
|
||||
# Configure Poetry to not create a virtual environment
|
||||
# and export dependencies to requirements.txt
|
||||
RUN poetry config virtualenvs.create false && \
|
||||
poetry export -f requirements.txt --output requirements.txt --without-hashes --only main
|
||||
|
||||
# ==========================================
|
||||
# STAGE 2: Runtime
|
||||
# ==========================================
|
||||
FROM python:3.11-slim as runtime
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONFAULTHANDLER=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create non-root user for security
|
||||
RUN useradd --create-home --shell /bin/bash app && \
|
||||
chown -R app:app /app
|
||||
USER app
|
||||
|
||||
# Copy requirements from builder stage
|
||||
COPY --from=builder /build/requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY --chown=app:app app/ ./app/
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health')" || exit 1
|
||||
|
||||
# Run the application with uvicorn
|
||||
# Using multiple workers for production (adjust based on CPU cores)
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||
270
backend/README.md
Normal file
270
backend/README.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# WealthWise Backend
|
||||
|
||||
Production-grade FastAPI backend for WealthWise financial analytics platform with self-hosted authentication.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.11+
|
||||
- Poetry 1.7+
|
||||
- Docker & Docker Compose (recommended)
|
||||
- PostgreSQL 15+
|
||||
|
||||
### Option 1: Docker Compose (Recommended)
|
||||
|
||||
The easiest way to get started with both database and backend:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# Start all services (database + backend)
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f backend
|
||||
|
||||
# Stop services
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
The API will be available at:
|
||||
- API Docs: http://localhost:8000/api/v1/docs
|
||||
- Health Check: http://localhost:8000/api/v1/health
|
||||
|
||||
### Option 2: Local Development with Docker Database
|
||||
|
||||
1. **Start the database:**
|
||||
```bash
|
||||
docker-compose up -d db
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
poetry install
|
||||
```
|
||||
|
||||
3. **Set up environment:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env if needed (default should work with Docker DB)
|
||||
```
|
||||
|
||||
4. **Run database migrations:**
|
||||
```bash
|
||||
# See ALEMBIC.md for detailed instructions
|
||||
poetry run alembic upgrade head
|
||||
```
|
||||
|
||||
5. **Run the server:**
|
||||
```bash
|
||||
poetry run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## Authentication System
|
||||
|
||||
WealthWise uses self-hosted JWT-based authentication with OAuth2 Password Flow.
|
||||
|
||||
### Registration
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "user@wealthwise.app",
|
||||
"password": "securepassword123"
|
||||
}'
|
||||
```
|
||||
|
||||
### Login
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/auth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "username=user@wealthwise.app&password=securepassword123"
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"token_type": "bearer"
|
||||
}
|
||||
```
|
||||
|
||||
### Access Protected Endpoint
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:8000/api/v1/users/me \
|
||||
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
|
||||
```
|
||||
|
||||
### Using Swagger UI
|
||||
|
||||
1. Open http://localhost:8000/api/v1/docs
|
||||
2. Click "Authorize" button (top right)
|
||||
3. Enter: `Bearer <your-token>` (replace with actual token)
|
||||
4. Or use the `/auth/token` endpoint to get a token, then use it
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
poetry run pytest
|
||||
|
||||
# Run with coverage
|
||||
poetry run pytest --cov=app --cov-report=html
|
||||
|
||||
# Run specific test file
|
||||
poetry run pytest tests/test_auth.py -v
|
||||
|
||||
# Run auth tests only
|
||||
poetry run pytest tests/test_auth.py -v
|
||||
```
|
||||
|
||||
## Database Migrations
|
||||
|
||||
See [ALEMBIC.md](ALEMBIC.md) for detailed migration instructions.
|
||||
|
||||
Quick commands:
|
||||
```bash
|
||||
# Create migration
|
||||
poetry run alembic revision --autogenerate -m "Description"
|
||||
|
||||
# Apply migrations
|
||||
poetry run alembic upgrade head
|
||||
|
||||
# View history
|
||||
poetry run alembic history
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── v1/
|
||||
│ │ │ ├── endpoints/
|
||||
│ │ │ │ ├── auth.py # Authentication endpoints
|
||||
│ │ │ │ ├── health.py # Health check endpoint
|
||||
│ │ │ │ └── users.py # User endpoints
|
||||
│ │ │ └── api.py # Router aggregation
|
||||
│ │ └── deps.py # Dependencies (DB session, auth)
|
||||
│ ├── core/
|
||||
│ │ ├── config.py # Settings management
|
||||
│ │ ├── db.py # Database engine & sessions
|
||||
│ │ └── security.py # Password hashing & JWT utilities
|
||||
│ ├── models/
|
||||
│ │ ├── __init__.py # Model exports
|
||||
│ │ ├── base.py # Base SQLModel classes
|
||||
│ │ ├── user.py # User model
|
||||
│ │ ├── portfolio.py # Portfolio model
|
||||
│ │ └── transaction.py # Transaction model
|
||||
│ ├── schemas/
|
||||
│ │ ├── __init__.py # Schema exports
|
||||
│ │ ├── token.py # Token schemas (legacy Supabase)
|
||||
│ │ └── user.py # User schemas
|
||||
│ └── main.py # FastAPI application factory
|
||||
├── tests/ # Test suite
|
||||
├── pyproject.toml # Poetry configuration
|
||||
├── Dockerfile # Multi-stage build
|
||||
├── docker-compose.yml # Docker Compose services
|
||||
├── .env.example # Environment template
|
||||
├── README.md # This file
|
||||
└── ALEMBIC.md # Migration guide
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See `.env.example` for all available configuration options.
|
||||
|
||||
### Required Variables
|
||||
- `DATABASE_URL`: PostgreSQL connection string
|
||||
- Local Docker: `postgresql+asyncpg://postgres:postgres@localhost:5432/wealthwise`
|
||||
- Production: `postgresql+asyncpg://user:pass@host:5432/dbname`
|
||||
- `SECRET_KEY`: JWT signing secret (generate with `openssl rand -hex 32`)
|
||||
- `ALGORITHM`: JWT algorithm (default: HS256)
|
||||
- `ACCESS_TOKEN_EXPIRE_MINUTES`: Token expiration (default: 30)
|
||||
|
||||
### Docker Compose Configuration
|
||||
The docker-compose.yml sets up:
|
||||
- PostgreSQL 15 with persistent volume
|
||||
- FastAPI backend with hot reload
|
||||
- Health checks for both services
|
||||
- Shared network for service communication
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
- `POST /api/v1/auth/register` - Register new user
|
||||
- `POST /api/v1/auth/token` - Login (OAuth2 Password Flow)
|
||||
|
||||
### Users (Protected)
|
||||
- `GET /api/v1/users/me` - Get current user info
|
||||
- `GET /api/v1/users/me/active` - Get current active user
|
||||
|
||||
### Health Check
|
||||
- `GET /api/v1/health` - Comprehensive health check with DB connectivity
|
||||
- `GET /api/v1/health/ready` - Kubernetes readiness probe
|
||||
- `GET /api/v1/health/live` - Kubernetes liveness probe
|
||||
|
||||
### Documentation
|
||||
- `GET /api/v1/docs` - Swagger UI
|
||||
- `GET /api/v1/redoc` - ReDoc UI
|
||||
- `GET /api/v1/openapi.json` - OpenAPI specification
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Adding New Endpoints
|
||||
1. Create endpoint file in `app/api/v1/endpoints/`
|
||||
2. Add router to `app/api/v1/api.py`
|
||||
3. Write tests in `tests/`
|
||||
|
||||
### Database Models
|
||||
1. Inherit from `BaseModel` in `app/models/base.py`
|
||||
2. Use SQLModel for type-safe ORM operations
|
||||
3. Create migration after model changes: `poetry run alembic revision --autogenerate -m "Description"`
|
||||
|
||||
### Authentication in Endpoints
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, Depends
|
||||
from app.api.deps import get_current_user
|
||||
from app.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/protected")
|
||||
async def protected_route(current_user: User = Depends(get_current_user)):
|
||||
return {"message": f"Hello {current_user.email}"}
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
- Format with Black: `poetry run black app tests`
|
||||
- Import sort with isort: `poetry run isort app tests`
|
||||
- Type check with mypy: `poetry run mypy app`
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Passwords**: Hashed using bcrypt (12 rounds by default)
|
||||
- **JWT Tokens**: HS256 algorithm, 30-minute expiration by default
|
||||
- **SECRET_KEY**: Must be changed in production! Use a strong random key.
|
||||
- **HTTPS**: Always use HTTPS in production to protect tokens
|
||||
- **CORS**: Configure CORS_ORIGINS to only allow your frontend domain
|
||||
|
||||
## Migration from Supabase
|
||||
|
||||
This backend has been migrated from Supabase Auth to self-hosted authentication:
|
||||
|
||||
- ✅ JWT validation uses local `SECRET_KEY` instead of Supabase
|
||||
- ✅ Password hashing with bcrypt
|
||||
- ✅ OAuth2 Password Flow endpoints
|
||||
- ✅ User, Portfolio, and Transaction models
|
||||
- ✅ Docker Compose for local development
|
||||
|
||||
## License
|
||||
|
||||
Private - WealthWise Financial Technologies
|
||||
149
backend/alembic.ini
Normal file
149
backend/alembic.ini
Normal file
@@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# 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 <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = postgresql://bshtechnologies:@localhost:5432/wealthwise
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
103
backend/alembic/env.py
Normal file
103
backend/alembic/env.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Import your models
|
||||
from app.models import User, Portfolio, Transaction
|
||||
from app.models.base import BaseModel
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
target_metadata = BaseModel.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
For async SQLAlchemy, we need to use async_engine_from_config
|
||||
and run the migrations in an async context.
|
||||
"""
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
|
||||
# Convert sync URL to async URL for the engine
|
||||
url = configuration.get('sqlalchemy.url', '').replace(
|
||||
'postgresql://', 'postgresql+asyncpg://'
|
||||
)
|
||||
configuration['sqlalchemy.url'] = url
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
backend/alembic/script.py.mako
Normal file
28
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Initial migration with user, portfolio, transaction
|
||||
|
||||
Revision ID: 5694f7d2f2e9
|
||||
Revises:
|
||||
Create Date: 2026-02-14 20:52:03.051559
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '5694f7d2f2e9'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# WealthWise Backend Application
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API package
|
||||
153
backend/app/api/deps.py
Normal file
153
backend/app/api/deps.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""WealthWise API dependencies.
|
||||
|
||||
This module provides reusable dependencies for FastAPI endpoints including:
|
||||
- Database session injection
|
||||
- Authentication dependencies with JWT validation
|
||||
- Common dependency utilities
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import get_session
|
||||
from app.models import User
|
||||
from app.schemas.user import TokenPayload
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# OAuth2 scheme for JWT token authentication
|
||||
# Uses the token endpoint for OAuth2 Password Flow
|
||||
oauth2_scheme = OAuth2PasswordBearer(
|
||||
tokenUrl=f"{settings.API_V1_STR}/auth/token",
|
||||
auto_error=False, # Allow optional auth for public endpoints
|
||||
)
|
||||
|
||||
|
||||
# Database session dependency
|
||||
# Re-export get_session for cleaner imports in endpoint modules
|
||||
SessionDep = Depends(get_session)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
session: AsyncSession = SessionDep,
|
||||
) -> User:
|
||||
"""Get current authenticated user from JWT token.
|
||||
|
||||
This dependency extracts the Bearer token from the Authorization header,
|
||||
validates it locally using the application's secret key, queries the
|
||||
database for the user, and returns the User model.
|
||||
|
||||
Args:
|
||||
token: JWT access token from Authorization header (Bearer token)
|
||||
session: Database session for user lookup
|
||||
|
||||
Returns:
|
||||
User: Authenticated user model from database
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if token is missing, invalid, or user not found/inactive
|
||||
"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not token:
|
||||
raise credentials_exception
|
||||
|
||||
try:
|
||||
# Decode and validate JWT
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.SECRET_KEY,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
|
||||
# Extract user ID from subject claim
|
||||
user_id: Optional[str] = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
|
||||
# Validate token type
|
||||
token_type = payload.get("type")
|
||||
if token_type != "access":
|
||||
raise credentials_exception
|
||||
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
# Query database for user
|
||||
statement = select(User).where(User.id == UUID(user_id))
|
||||
result = await session.exec(statement)
|
||||
user = result.first()
|
||||
|
||||
# Validate user exists and is active
|
||||
if user is None or not user.is_active:
|
||||
raise credentials_exception
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""Verify user is active (non-superuser check optional).
|
||||
|
||||
This dependency extends get_current_user. Currently just returns the user
|
||||
since get_current_user already checks is_active. Can be extended for
|
||||
additional checks like email verification.
|
||||
|
||||
Args:
|
||||
current_user: User from get_current_user dependency
|
||||
|
||||
Returns:
|
||||
Active User model
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if user is inactive
|
||||
"""
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_current_superuser(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""Verify user is a superuser/admin.
|
||||
|
||||
This dependency requires the user to have superuser privileges.
|
||||
|
||||
Args:
|
||||
current_user: User from get_current_user dependency
|
||||
|
||||
Returns:
|
||||
Superuser User model
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if user is not a superuser
|
||||
"""
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not enough permissions",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
# Convenience exports for endpoint modules
|
||||
__all__ = [
|
||||
"get_session",
|
||||
"SessionDep",
|
||||
"oauth2_scheme",
|
||||
"get_current_user",
|
||||
"get_current_active_user",
|
||||
"get_current_superuser",
|
||||
]
|
||||
1
backend/app/api/v1/__init__.py
Normal file
1
backend/app/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API v1 endpoints
|
||||
38
backend/app/api/v1/api.py
Normal file
38
backend/app/api/v1/api.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""WealthWise API v1 router aggregation.
|
||||
|
||||
This module aggregates all v1 API endpoints into a single router.
|
||||
Each feature domain should register its endpoints here.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import auth, health, users
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# Health check endpoints
|
||||
api_router.include_router(
|
||||
health.router,
|
||||
prefix="/health",
|
||||
tags=["health"],
|
||||
)
|
||||
|
||||
# Authentication endpoints
|
||||
api_router.include_router(
|
||||
auth.router,
|
||||
prefix="/auth",
|
||||
tags=["authentication"],
|
||||
)
|
||||
|
||||
# User endpoints (requires authentication)
|
||||
api_router.include_router(
|
||||
users.router,
|
||||
prefix="/users",
|
||||
tags=["users"],
|
||||
)
|
||||
|
||||
# TODO: Add more endpoint routers as features are implemented
|
||||
# Example:
|
||||
# from app.api.v1.endpoints import portfolios, transactions
|
||||
# api_router.include_router(portfolios.router, prefix="/portfolios", tags=["portfolios"])
|
||||
# api_router.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
|
||||
1
backend/app/api/v1/endpoints/__init__.py
Normal file
1
backend/app/api/v1/endpoints/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API v1 endpoints package
|
||||
131
backend/app/api/v1/endpoints/auth.py
Normal file
131
backend/app/api/v1/endpoints/auth.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Authentication endpoints for WealthWise.
|
||||
|
||||
This module provides endpoints for user registration, login, and token management
|
||||
using OAuth2 with Password Flow (JWT-based authentication).
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.api.deps import SessionDep
|
||||
from app.core.security import create_access_token, get_password_hash, verify_password
|
||||
from app.models import User
|
||||
from app.schemas.user import Token, UserCreate, UserPublic
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
response_model=UserPublic,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Register a new user",
|
||||
description="Create a new user account with email and password.",
|
||||
response_description="Created user information (excluding password)",
|
||||
tags=["Authentication"],
|
||||
)
|
||||
async def register(
|
||||
user_in: UserCreate,
|
||||
session: AsyncSession = SessionDep,
|
||||
) -> UserPublic:
|
||||
"""Register a new user account.
|
||||
|
||||
This endpoint creates a new user with the provided email and password.
|
||||
The password is hashed using bcrypt before storage. If the email already
|
||||
exists, a 400 error is returned.
|
||||
|
||||
Args:
|
||||
user_in: User creation data with email and password
|
||||
session: Database session
|
||||
|
||||
Returns:
|
||||
UserPublic: Created user information (without password)
|
||||
|
||||
Raises:
|
||||
HTTPException: 400 if email already registered
|
||||
"""
|
||||
# Check if user with this email already exists
|
||||
statement = select(User).where(User.email == user_in.email)
|
||||
result = await session.exec(statement)
|
||||
existing_user = result.first()
|
||||
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered",
|
||||
)
|
||||
|
||||
# Create new user with hashed password
|
||||
user = User(
|
||||
email=user_in.email,
|
||||
hashed_password=get_password_hash(user_in.password),
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
)
|
||||
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.post(
|
||||
"/token",
|
||||
response_model=Token,
|
||||
summary="Login and get access token",
|
||||
description="Authenticate with email and password to receive a JWT access token.",
|
||||
response_description="JWT access token for authenticated requests",
|
||||
tags=["Authentication"],
|
||||
)
|
||||
async def login_for_access_token(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
session: AsyncSession = SessionDep,
|
||||
) -> Token:
|
||||
"""Authenticate user and return JWT access token.
|
||||
|
||||
This endpoint accepts username (email) and password via OAuth2 form data,
|
||||
validates the credentials against the database, and returns a JWT access
|
||||
token if authentication is successful.
|
||||
|
||||
Args:
|
||||
form_data: OAuth2 form with username (email) and password
|
||||
session: Database session
|
||||
|
||||
Returns:
|
||||
Token: JWT access token with bearer type
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if credentials are invalid
|
||||
"""
|
||||
# Find user by email (username field in OAuth2 form)
|
||||
statement = select(User).where(User.email == form_data.username)
|
||||
result = await session.exec(statement)
|
||||
user = result.first()
|
||||
|
||||
# Validate user exists, is active, and password is correct
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if not verify_password(form_data.password, user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Create access token
|
||||
access_token = create_access_token(
|
||||
data={"sub": str(user.id)},
|
||||
)
|
||||
|
||||
return Token(access_token=access_token, token_type="bearer")
|
||||
111
backend/app/api/v1/endpoints/health.py
Normal file
111
backend/app/api/v1/endpoints/health.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Health check endpoint for WealthWise API.
|
||||
|
||||
This module provides a comprehensive health check endpoint that verifies:
|
||||
- Application is running
|
||||
- Database connectivity
|
||||
- Overall system status
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import text
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.api.deps import SessionDep
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import check_db_connection
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/health",
|
||||
summary="Health check endpoint",
|
||||
description="Performs comprehensive health checks including database connectivity.",
|
||||
response_description="Health status with version and database state",
|
||||
tags=["Health"],
|
||||
)
|
||||
async def health_check(session: AsyncSession = SessionDep) -> dict:
|
||||
"""Check application and database health.
|
||||
|
||||
This endpoint performs an actual database query (SELECT 1) to verify
|
||||
that the database connection is working properly. It returns a detailed
|
||||
health status including:
|
||||
- Overall application status
|
||||
- Database connectivity state
|
||||
- Application version
|
||||
|
||||
Returns:
|
||||
dict: Health status object with the following structure:
|
||||
{
|
||||
"status": "healthy" | "degraded",
|
||||
"database": "connected" | "disconnected",
|
||||
"version": "1.0.0",
|
||||
"timestamp": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
|
||||
Raises:
|
||||
HTTPException: 503 if database is unreachable
|
||||
"""
|
||||
health_status = {
|
||||
"status": "healthy",
|
||||
"database": "connected",
|
||||
"version": settings.VERSION,
|
||||
}
|
||||
|
||||
try:
|
||||
# Perform actual database query to verify connectivity
|
||||
result = await session.execute(text("SELECT 1"))
|
||||
db_response = result.scalar()
|
||||
|
||||
if db_response != 1:
|
||||
raise Exception("Unexpected database response")
|
||||
|
||||
except Exception as e:
|
||||
health_status.update({
|
||||
"status": "degraded",
|
||||
"database": "disconnected",
|
||||
"error": str(e),
|
||||
})
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=health_status,
|
||||
)
|
||||
|
||||
return health_status
|
||||
|
||||
|
||||
@router.get(
|
||||
"/health/ready",
|
||||
summary="Readiness probe",
|
||||
description="Kubernetes-style readiness probe endpoint.",
|
||||
tags=["Health"],
|
||||
)
|
||||
async def readiness_probe() -> dict:
|
||||
"""Kubernetes readiness probe.
|
||||
|
||||
Returns 200 if the application is ready to receive traffic.
|
||||
Used by container orchestrators to determine when to route traffic.
|
||||
|
||||
Returns:
|
||||
dict: Simple status object
|
||||
"""
|
||||
return {"ready": True}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/health/live",
|
||||
summary="Liveness probe",
|
||||
description="Kubernetes-style liveness probe endpoint.",
|
||||
tags=["Health"],
|
||||
)
|
||||
async def liveness_probe() -> dict:
|
||||
"""Kubernetes liveness probe.
|
||||
|
||||
Returns 200 if the application is alive and should not be restarted.
|
||||
Used by container orchestrators to detect deadlocks or stuck processes.
|
||||
|
||||
Returns:
|
||||
dict: Simple status object
|
||||
"""
|
||||
return {"alive": True}
|
||||
63
backend/app/api/v1/endpoints/users.py
Normal file
63
backend/app/api/v1/endpoints/users.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""User endpoints for WealthWise API.
|
||||
|
||||
This module provides endpoints for user management and profile operations.
|
||||
All endpoints require authentication via JWT tokens.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.deps import get_current_active_user, get_current_user
|
||||
from app.models import User
|
||||
from app.schemas.user import UserPublic
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=UserPublic,
|
||||
summary="Get current user information",
|
||||
description="Returns the authenticated user's profile information. "
|
||||
"This endpoint proves that authentication is working correctly.",
|
||||
response_description="User information (id, email, is_active)",
|
||||
tags=["Users"],
|
||||
)
|
||||
async def get_current_user_info(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UserPublic:
|
||||
"""Get current authenticated user's information.
|
||||
|
||||
This endpoint returns the user's basic information from the database.
|
||||
It serves as a simple way to verify that authentication is working.
|
||||
|
||||
Args:
|
||||
current_user: User model injected by get_current_user dependency
|
||||
|
||||
Returns:
|
||||
UserPublic with the user's id, email, and account status
|
||||
"""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me/active",
|
||||
response_model=UserPublic,
|
||||
summary="Get current active user",
|
||||
description="Returns the authenticated user's profile. "
|
||||
"Demonstrates the get_current_active_user dependency.",
|
||||
tags=["Users"],
|
||||
)
|
||||
async def get_active_user_info(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
) -> UserPublic:
|
||||
"""Get current active user's information.
|
||||
|
||||
This endpoint demonstrates the get_current_active_user dependency.
|
||||
|
||||
Args:
|
||||
current_user: User model injected by get_current_active_user dependency
|
||||
|
||||
Returns:
|
||||
UserPublic with the user's information
|
||||
"""
|
||||
return current_user
|
||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Core package - configuration and database
|
||||
129
backend/app/core/config.py
Normal file
129
backend/app/core/config.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""WealthWise application configuration management.
|
||||
|
||||
This module provides centralized configuration management using Pydantic Settings v2.
|
||||
Configuration values are loaded from environment variables and .env files.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings management.
|
||||
|
||||
All configuration values are loaded from environment variables.
|
||||
The .env file is automatically loaded when present.
|
||||
System environment variables override values in .env.
|
||||
|
||||
Attributes:
|
||||
PROJECT_NAME: Application name for documentation and logging
|
||||
API_V1_STR: Base path for API v1 endpoints
|
||||
VERSION: Application version string
|
||||
DATABASE_URL: PostgreSQL connection string (Supabase Transaction Pooler format)
|
||||
SUPABASE_JWT_SECRET: JWT secret for Supabase authentication
|
||||
DEBUG: Enable debug mode (default: False)
|
||||
CORS_ORIGINS: Comma-separated list of allowed CORS origins
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore", # Allow extra env vars without raising errors
|
||||
case_sensitive=True,
|
||||
)
|
||||
|
||||
# Application Info
|
||||
PROJECT_NAME: str = Field(default="WealthWise", description="Application name")
|
||||
API_V1_STR: str = Field(default="/api/v1", description="API v1 base path")
|
||||
VERSION: str = Field(default="1.0.0", description="Application version")
|
||||
|
||||
# Security
|
||||
SECRET_KEY: str = Field(
|
||||
default="dev-secret-key-change-in-production",
|
||||
description="Secret key for JWT signing",
|
||||
)
|
||||
ALGORITHM: str = Field(
|
||||
default="HS256",
|
||||
description="JWT signing algorithm",
|
||||
)
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(
|
||||
default=30,
|
||||
description="JWT token expiration in minutes",
|
||||
)
|
||||
|
||||
# Database - Local PostgreSQL (Docker)
|
||||
DATABASE_URL: str = Field(
|
||||
default="postgresql+asyncpg://postgres:postgres@localhost:5432/wealthwise",
|
||||
description="PostgreSQL async connection string",
|
||||
)
|
||||
|
||||
# Database Pool Configuration
|
||||
DB_POOL_SIZE: int = Field(default=20, ge=1, le=100, description="Connection pool size")
|
||||
DB_MAX_OVERFLOW: int = Field(default=10, ge=0, le=50, description="Max overflow connections")
|
||||
DB_POOL_PRE_PING: bool = Field(
|
||||
default=True,
|
||||
description="Verify connections before using from pool",
|
||||
)
|
||||
DB_ECHO: bool = Field(default=False, description="Echo SQL queries to stdout")
|
||||
|
||||
# API Configuration
|
||||
DEBUG: bool = Field(default=False, description="Debug mode")
|
||||
CORS_ORIGINS: str = Field(
|
||||
default="http://localhost:5173,http://localhost:3000",
|
||||
description="Comma-separated list of allowed CORS origins",
|
||||
)
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$")
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
"""Parse CORS_ORIGINS string into a list.
|
||||
|
||||
Returns:
|
||||
List of origin strings
|
||||
"""
|
||||
return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()]
|
||||
|
||||
@field_validator("DATABASE_URL")
|
||||
@classmethod
|
||||
def validate_database_url(cls, v: Optional[str]) -> Any:
|
||||
"""Validate and ensure proper asyncpg driver in DATABASE_URL.
|
||||
|
||||
Args:
|
||||
v: Database URL string
|
||||
|
||||
Returns:
|
||||
Validated database URL with asyncpg driver
|
||||
|
||||
Raises:
|
||||
ValueError: If URL format is invalid
|
||||
"""
|
||||
if not v:
|
||||
raise ValueError("DATABASE_URL cannot be empty")
|
||||
|
||||
# Ensure asyncpg driver is used
|
||||
if v.startswith("postgresql://"):
|
||||
v = v.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
elif not v.startswith("postgresql+asyncpg://"):
|
||||
raise ValueError(
|
||||
"DATABASE_URL must use postgresql+asyncpg:// driver for async support"
|
||||
)
|
||||
|
||||
return v
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance.
|
||||
|
||||
This function uses LRU caching to avoid re-reading configuration
|
||||
on every call. Settings are loaded once at application startup.
|
||||
|
||||
Returns:
|
||||
Settings instance with all configuration values
|
||||
"""
|
||||
return Settings()
|
||||
123
backend/app/core/db.py
Normal file
123
backend/app/core/db.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""WealthWise database configuration and session management.
|
||||
|
||||
This module provides:
|
||||
- Async SQLAlchemy engine with connection pooling
|
||||
- Async session factory for database operations
|
||||
- FastAPI dependency for session injection
|
||||
- Connection health checking utilities
|
||||
"""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.pool import NullPool
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession as SQLModelAsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
# Create async engine with connection pooling
|
||||
# Supabase Transaction Pooler (port 6543) supports high concurrency
|
||||
engine: AsyncEngine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DB_ECHO,
|
||||
pool_size=settings.DB_POOL_SIZE,
|
||||
max_overflow=settings.DB_MAX_OVERFLOW,
|
||||
pool_pre_ping=settings.DB_POOL_PRE_PING,
|
||||
# Connection pool settings for production
|
||||
pool_recycle=3600, # Recycle connections after 1 hour
|
||||
pool_timeout=30, # Wait up to 30 seconds for available connection
|
||||
# Async-specific settings
|
||||
future=True,
|
||||
)
|
||||
|
||||
# Create async session factory
|
||||
# expire_on_commit=False allows accessing attributes after session closes
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=SQLModelAsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[SQLModelAsyncSession, None]:
|
||||
"""FastAPI dependency that provides an async database session.
|
||||
|
||||
This generator yields a database session for use in API endpoints.
|
||||
It automatically handles transaction rollback on errors and ensures
|
||||
proper session cleanup.
|
||||
|
||||
Usage:
|
||||
@app.get("/items")
|
||||
async def get_items(session: AsyncSession = Depends(get_session)):
|
||||
result = await session.execute(select(Item))
|
||||
return result.scalars().all()
|
||||
|
||||
Yields:
|
||||
AsyncSession: Database session instance
|
||||
|
||||
Raises:
|
||||
SQLAlchemyError: Re-raised after rollback if database error occurs
|
||||
"""
|
||||
session: SQLModelAsyncSession = AsyncSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except SQLAlchemyError as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def close_engine() -> None:
|
||||
"""Close all database connections.
|
||||
|
||||
Call this during application shutdown to properly release
|
||||
all database connections in the pool.
|
||||
"""
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def check_db_connection() -> bool:
|
||||
"""Verify database connectivity by executing a simple query.
|
||||
|
||||
Returns:
|
||||
True if database is accessible, False otherwise
|
||||
"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute("SELECT 1")
|
||||
return result.scalar() == 1
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# For Alembic migrations (sync operations)
|
||||
def create_sync_engine():
|
||||
"""Create synchronous engine for Alembic migrations.
|
||||
|
||||
Returns:
|
||||
SyncEngine: Synchronous SQLAlchemy engine
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
# Convert async URL to sync URL
|
||||
sync_url = settings.DATABASE_URL.replace(
|
||||
"postgresql+asyncpg://", "postgresql://"
|
||||
)
|
||||
|
||||
return create_engine(
|
||||
sync_url,
|
||||
echo=settings.DB_ECHO,
|
||||
)
|
||||
112
backend/app/core/security.py
Normal file
112
backend/app/core/security.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Security utilities for authentication and password management.
|
||||
|
||||
This module provides:
|
||||
- Password hashing and verification using bcrypt
|
||||
- JWT token creation and validation
|
||||
- Security-related helper functions
|
||||
"""
|
||||
|
||||
import bcrypt
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from jose import jwt
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a plain password against a hashed password.
|
||||
|
||||
Args:
|
||||
plain_password: The plain text password to verify
|
||||
hashed_password: The bcrypt hashed password from database
|
||||
|
||||
Returns:
|
||||
True if password matches, False otherwise
|
||||
"""
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode('utf-8'),
|
||||
hashed_password.encode('utf-8')
|
||||
)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Generate a bcrypt hash from a plain password.
|
||||
|
||||
Args:
|
||||
password: The plain text password to hash
|
||||
|
||||
Returns:
|
||||
Bcrypt hashed password string
|
||||
"""
|
||||
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
|
||||
def create_access_token(
|
||||
data: dict[str, Any],
|
||||
expires_delta: Optional[timedelta] = None,
|
||||
) -> str:
|
||||
"""Create a JWT access token.
|
||||
|
||||
This function creates a JWT token with the provided data payload.
|
||||
The token includes an expiration time and is signed with the
|
||||
application's secret key.
|
||||
|
||||
Args:
|
||||
data: Dictionary of claims to encode in the token (e.g., {"sub": user_id})
|
||||
expires_delta: Optional custom expiration time. Defaults to settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
|
||||
Returns:
|
||||
Encoded JWT string
|
||||
|
||||
Example:
|
||||
>>> token = create_access_token({"sub": str(user.id)})
|
||||
>>> # Use token in Authorization: Bearer <token> header
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
|
||||
# Calculate expiration time
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
|
||||
# Add expiration and issued at claims
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
"type": "access",
|
||||
})
|
||||
|
||||
# Encode the JWT
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode,
|
||||
settings.SECRET_KEY,
|
||||
algorithm=settings.ALGORITHM,
|
||||
)
|
||||
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any]:
|
||||
"""Decode and validate a JWT access token.
|
||||
|
||||
Args:
|
||||
token: The JWT string to decode
|
||||
|
||||
Returns:
|
||||
Dictionary containing the token payload
|
||||
|
||||
Raises:
|
||||
jwt.JWTError: If token is invalid or expired
|
||||
"""
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.SECRET_KEY,
|
||||
algorithms=[settings.ALGORITHM],
|
||||
)
|
||||
130
backend/app/main.py
Normal file
130
backend/app/main.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""WealthWise FastAPI application factory.
|
||||
|
||||
This module initializes and configures the FastAPI application with:
|
||||
- Middleware configuration (CORS, logging, error handling)
|
||||
- API router registration
|
||||
- Lifecycle event handlers (startup/shutdown)
|
||||
- Exception handlers
|
||||
"""
|
||||
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api.v1.api import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import check_db_connection, close_engine
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan context manager.
|
||||
|
||||
Handles startup and shutdown events:
|
||||
- Startup: Verify database connectivity, initialize caches
|
||||
- Shutdown: Close database connections, cleanup resources
|
||||
|
||||
Args:
|
||||
app: FastAPI application instance
|
||||
|
||||
Yields:
|
||||
None: Application runs during this period
|
||||
"""
|
||||
# Startup
|
||||
print(f"Starting {settings.PROJECT_NAME} v{settings.VERSION}")
|
||||
|
||||
# Verify database connectivity on startup
|
||||
db_healthy = await check_db_connection()
|
||||
if not db_healthy:
|
||||
print("WARNING: Database connection failed on startup!")
|
||||
else:
|
||||
print("Database connection established")
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
print(f"Shutting down {settings.PROJECT_NAME}")
|
||||
await close_engine()
|
||||
print("Database connections closed")
|
||||
|
||||
|
||||
def create_application() -> FastAPI:
|
||||
"""Create and configure FastAPI application.
|
||||
|
||||
Returns:
|
||||
Configured FastAPI application instance
|
||||
"""
|
||||
application = FastAPI(
|
||||
title=settings.PROJECT_NAME,
|
||||
description="Production-grade financial analytics platform API",
|
||||
version=settings.VERSION,
|
||||
openapi_url=f"{settings.API_V1_STR}/openapi.json",
|
||||
docs_url=f"{settings.API_V1_STR}/docs",
|
||||
redoc_url=f"{settings.API_V1_STR}/redoc",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS Middleware
|
||||
# Explicitly allow Authorization header for JWT Bearer tokens
|
||||
application.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"], # Includes Authorization header
|
||||
expose_headers=["X-Process-Time"], # Expose custom headers
|
||||
)
|
||||
|
||||
# Gzip compression for responses
|
||||
application.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
# Request timing middleware
|
||||
@application.middleware("http")
|
||||
async def add_process_time_header(request: Request, call_next):
|
||||
"""Add X-Process-Time header to all responses."""
|
||||
start_time = time.time()
|
||||
response = await call_next(request)
|
||||
process_time = time.time() - start_time
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
return response
|
||||
|
||||
# Include API routers
|
||||
application.include_router(
|
||||
api_router,
|
||||
prefix=settings.API_V1_STR,
|
||||
)
|
||||
|
||||
# Root endpoint
|
||||
@application.get("/")
|
||||
async def root():
|
||||
"""Root endpoint - API information."""
|
||||
return {
|
||||
"name": settings.PROJECT_NAME,
|
||||
"version": settings.VERSION,
|
||||
"docs": f"{settings.API_V1_STR}/docs",
|
||||
"health": f"{settings.API_V1_STR}/health",
|
||||
}
|
||||
|
||||
# Global exception handlers
|
||||
@application.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
"""Handle uncaught exceptions."""
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"error": "Internal server error",
|
||||
"message": str(exc) if settings.DEBUG else "An unexpected error occurred",
|
||||
},
|
||||
)
|
||||
|
||||
return application
|
||||
|
||||
|
||||
# Create the application instance
|
||||
app = create_application()
|
||||
25
backend/app/models/__init__.py
Normal file
25
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""WealthWise database models.
|
||||
|
||||
This package contains all SQLModel database models for the application.
|
||||
"""
|
||||
|
||||
from app.models.base import BaseModel, TimestampMixin
|
||||
from app.models.portfolio import Portfolio, PortfolioBase
|
||||
from app.models.transaction import Transaction, TransactionBase, TransactionType
|
||||
from app.models.user import User, UserBase
|
||||
|
||||
__all__ = [
|
||||
# Base models
|
||||
"BaseModel",
|
||||
"TimestampMixin",
|
||||
# User models
|
||||
"User",
|
||||
"UserBase",
|
||||
# Portfolio models
|
||||
"Portfolio",
|
||||
"PortfolioBase",
|
||||
# Transaction models
|
||||
"Transaction",
|
||||
"TransactionBase",
|
||||
"TransactionType",
|
||||
]
|
||||
88
backend/app/models/base.py
Normal file
88
backend/app/models/base.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Base SQLModel configuration for WealthWise database models.
|
||||
|
||||
This module provides the base model class with common fields and utilities
|
||||
for all database entities.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class BaseModel(SQLModel):
|
||||
"""Base model with common fields for all database tables.
|
||||
|
||||
All models should inherit from this class to get:
|
||||
- UUID primary key
|
||||
- Created and updated timestamps
|
||||
- Soft delete support
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier (UUID)
|
||||
created_at: Timestamp when record was created
|
||||
updated_at: Timestamp when record was last updated
|
||||
deleted_at: Timestamp for soft deletes (None if active)
|
||||
"""
|
||||
|
||||
id: Optional[UUID] = Field(
|
||||
default_factory=uuid4,
|
||||
primary_key=True,
|
||||
index=True,
|
||||
description="Unique identifier",
|
||||
)
|
||||
|
||||
created_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
sa_column_kwargs={
|
||||
"server_default": func.now(),
|
||||
"nullable": False,
|
||||
},
|
||||
description="Timestamp when record was created",
|
||||
)
|
||||
|
||||
updated_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
sa_column_kwargs={
|
||||
"server_default": func.now(),
|
||||
"onupdate": func.now(),
|
||||
"nullable": False,
|
||||
},
|
||||
description="Timestamp when record was last updated",
|
||||
)
|
||||
|
||||
deleted_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
description="Timestamp for soft delete (None if record is active)",
|
||||
)
|
||||
|
||||
class Config:
|
||||
"""Pydantic configuration."""
|
||||
# Allow arbitrary types for UUID handling
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
|
||||
class TimestampMixin(SQLModel):
|
||||
"""Mixin for models that only need timestamp fields (no ID).
|
||||
|
||||
Use this for association tables or when ID is handled differently.
|
||||
"""
|
||||
|
||||
created_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
sa_column_kwargs={
|
||||
"server_default": func.now(),
|
||||
"nullable": False,
|
||||
},
|
||||
)
|
||||
|
||||
updated_at: Optional[datetime] = Field(
|
||||
default=None,
|
||||
sa_column_kwargs={
|
||||
"server_default": func.now(),
|
||||
"onupdate": func.now(),
|
||||
"nullable": False,
|
||||
},
|
||||
)
|
||||
76
backend/app/models/portfolio.py
Normal file
76
backend/app/models/portfolio.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Portfolio model for WealthWise.
|
||||
|
||||
This module defines the Portfolio database model. Portfolios belong to users
|
||||
and contain multiple transactions. Users can have multiple portfolios for
|
||||
different investment strategies or purposes.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, String, Text
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
|
||||
from app.models.base import BaseModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.transaction import Transaction
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class PortfolioBase(SQLModel):
|
||||
"""Base Portfolio model with common attributes.
|
||||
|
||||
Attributes:
|
||||
name: Portfolio name
|
||||
description: Optional portfolio description
|
||||
"""
|
||||
|
||||
name: str = Field(
|
||||
sa_column=Column(String(255), nullable=False),
|
||||
description="Portfolio name",
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
default=None,
|
||||
sa_column=Column(Text, nullable=True),
|
||||
description="Optional portfolio description",
|
||||
)
|
||||
|
||||
|
||||
class Portfolio(BaseModel, PortfolioBase, table=True):
|
||||
"""Portfolio database model.
|
||||
|
||||
Portfolios represent collections of financial transactions owned by a user.
|
||||
Each portfolio tracks investments, expenses, or savings for a specific purpose.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key (inherited from BaseModel)
|
||||
user_id: Foreign key to users table
|
||||
name: Portfolio name
|
||||
description: Optional description
|
||||
user: Relationship to owner
|
||||
transactions: Relationship to portfolio transactions
|
||||
created_at: Timestamp (inherited from BaseModel)
|
||||
updated_at: Timestamp (inherited from BaseModel)
|
||||
"""
|
||||
|
||||
__tablename__ = "portfolios"
|
||||
|
||||
user_id: UUID = Field(
|
||||
sa_column=Column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
description="Owner user ID",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
user: "User" = Relationship(back_populates="portfolios")
|
||||
transactions: List["Transaction"] = Relationship(
|
||||
back_populates="portfolio",
|
||||
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
|
||||
)
|
||||
|
||||
class Config:
|
||||
"""Pydantic configuration."""
|
||||
arbitrary_types_allowed = True
|
||||
104
backend/app/models/transaction.py
Normal file
104
backend/app/models/transaction.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Transaction model for WealthWise.
|
||||
|
||||
This module defines the Transaction database model. Transactions represent
|
||||
individual financial entries (income, expenses, transfers) within a portfolio.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Column, Date, DateTime, ForeignKey, Numeric, String, Text
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
|
||||
from app.models.base import BaseModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.portfolio import Portfolio
|
||||
|
||||
|
||||
class TransactionType(str, Enum):
|
||||
"""Transaction type enumeration.
|
||||
|
||||
Defines the possible types of financial transactions:
|
||||
- INCOME: Money coming in (salary, dividends, etc.)
|
||||
- EXPENSE: Money going out (purchases, bills, etc.)
|
||||
- TRANSFER: Movement between accounts/portfolios
|
||||
"""
|
||||
INCOME = "income"
|
||||
EXPENSE = "expense"
|
||||
TRANSFER = "transfer"
|
||||
|
||||
|
||||
class TransactionBase(SQLModel):
|
||||
"""Base Transaction model with common attributes.
|
||||
|
||||
Attributes:
|
||||
amount: Transaction amount (Decimal for financial precision)
|
||||
transaction_type: Transaction type (income, expense, transfer)
|
||||
date: Transaction date
|
||||
description: Optional transaction description
|
||||
category: Optional transaction category/tag
|
||||
"""
|
||||
|
||||
amount: Decimal = Field(
|
||||
sa_column=Column(Numeric(15, 2), nullable=False),
|
||||
description="Transaction amount",
|
||||
)
|
||||
transaction_type: TransactionType = Field(
|
||||
sa_column=Column(String(20), nullable=False),
|
||||
description="Transaction type: income, expense, or transfer",
|
||||
)
|
||||
transaction_date: dt.date = Field(
|
||||
sa_column=Column(Date, nullable=False),
|
||||
description="Transaction date",
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
default=None,
|
||||
sa_column=Column(Text, nullable=True),
|
||||
description="Optional transaction description",
|
||||
)
|
||||
category: Optional[str] = Field(
|
||||
default=None,
|
||||
sa_column=Column(String(100), nullable=True),
|
||||
description="Optional transaction category or tag",
|
||||
)
|
||||
|
||||
|
||||
class Transaction(BaseModel, TransactionBase, table=True):
|
||||
"""Transaction database model.
|
||||
|
||||
Transactions represent individual financial entries within a portfolio.
|
||||
They track amounts, dates, types, and categorization for financial analysis.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key (inherited from BaseModel)
|
||||
portfolio_id: Foreign key to portfolios table
|
||||
amount: Transaction amount with 2 decimal precision
|
||||
transaction_type: Transaction type enum
|
||||
date: Transaction date
|
||||
description: Optional description
|
||||
category: Optional category/tag
|
||||
portfolio: Relationship to parent portfolio
|
||||
created_at: Timestamp (inherited from BaseModel)
|
||||
updated_at: Not used (transactions are immutable after creation)
|
||||
"""
|
||||
|
||||
__tablename__ = "transactions"
|
||||
|
||||
portfolio_id: UUID = Field(
|
||||
sa_column=Column(
|
||||
ForeignKey("portfolios.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
description="Parent portfolio ID",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
portfolio: "Portfolio" = Relationship(back_populates="transactions")
|
||||
|
||||
class Config:
|
||||
"""Pydantic configuration."""
|
||||
arbitrary_types_allowed = True
|
||||
76
backend/app/models/user.py
Normal file
76
backend/app/models/user.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""User model for WealthWise.
|
||||
|
||||
This module defines the User database model for self-hosted authentication.
|
||||
Users can have multiple portfolios and are authenticated via JWT tokens.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Boolean, Column, String
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
|
||||
from app.models.base import BaseModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.portfolio import Portfolio
|
||||
|
||||
|
||||
class UserBase(SQLModel):
|
||||
"""Base User model with common attributes.
|
||||
|
||||
Attributes:
|
||||
email: User's unique email address (indexed)
|
||||
is_active: Whether the user account is active
|
||||
is_superuser: Whether user has admin privileges
|
||||
"""
|
||||
|
||||
email: str = Field(
|
||||
sa_column=Column(String, unique=True, index=True, nullable=False),
|
||||
description="User's unique email address",
|
||||
)
|
||||
is_active: bool = Field(
|
||||
default=True,
|
||||
sa_column=Column(Boolean, default=True, nullable=False),
|
||||
description="Whether the user account is active",
|
||||
)
|
||||
is_superuser: bool = Field(
|
||||
default=False,
|
||||
sa_column=Column(Boolean, default=False, nullable=False),
|
||||
description="Whether user has admin privileges",
|
||||
)
|
||||
|
||||
|
||||
class User(BaseModel, UserBase, table=True):
|
||||
"""User database model.
|
||||
|
||||
This model stores user information including authentication credentials
|
||||
and account status. Passwords are stored as bcrypt hashes.
|
||||
|
||||
Attributes:
|
||||
id: UUID primary key (inherited from BaseModel)
|
||||
email: Unique email address
|
||||
hashed_password: Bcrypt hashed password
|
||||
is_active: Account status
|
||||
is_superuser: Admin flag
|
||||
portfolios: Relationship to user's portfolios
|
||||
created_at: Timestamp (inherited from BaseModel)
|
||||
updated_at: Timestamp (inherited from BaseModel)
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
hashed_password: str = Field(
|
||||
sa_column=Column(String, nullable=False),
|
||||
description="Bcrypt hashed password",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
portfolios: List["Portfolio"] = Relationship(
|
||||
back_populates="user",
|
||||
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
|
||||
)
|
||||
|
||||
class Config:
|
||||
"""Pydantic configuration."""
|
||||
arbitrary_types_allowed = True
|
||||
26
backend/app/schemas/__init__.py
Normal file
26
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Schemas package for Pydantic models
|
||||
|
||||
from app.schemas.token import TokenPayload, UserContext
|
||||
from app.schemas.user import (
|
||||
Token,
|
||||
TokenPayload as UserTokenPayload,
|
||||
UserBase,
|
||||
UserCreate,
|
||||
UserInDB,
|
||||
UserPublic,
|
||||
UserUpdate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Token schemas (legacy Supabase support)
|
||||
"TokenPayload",
|
||||
"UserContext",
|
||||
# User schemas (new self-hosted auth)
|
||||
"Token",
|
||||
"UserTokenPayload",
|
||||
"UserBase",
|
||||
"UserCreate",
|
||||
"UserPublic",
|
||||
"UserInDB",
|
||||
"UserUpdate",
|
||||
]
|
||||
127
backend/app/schemas/token.py
Normal file
127
backend/app/schemas/token.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Pydantic models for JWT token handling and user context.
|
||||
|
||||
This module defines the data structures for:
|
||||
- TokenPayload: Full JWT payload from Supabase
|
||||
- UserContext: Clean user representation for API context
|
||||
"""
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
"""Supabase JWT token payload structure.
|
||||
|
||||
This model represents the complete JWT payload that Supabase Auth
|
||||
includes in the access token. It contains all claims including
|
||||
standard JWT claims and Supabase-specific claims.
|
||||
|
||||
Attributes:
|
||||
sub: Subject (user UUID)
|
||||
exp: Expiration timestamp (Unix)
|
||||
iat: Issued at timestamp (Unix)
|
||||
aud: Audience (should be "authenticated")
|
||||
email: User's email address
|
||||
phone: User's phone number (if available)
|
||||
app_metadata: Application-specific metadata (includes role)
|
||||
user_metadata: User-specific metadata
|
||||
role: User's database role (e.g., "authenticated", "anon")
|
||||
aal: Authenticator assurance level (aal1, aal2)
|
||||
amr: Authentication methods reference
|
||||
session_id: Session UUID
|
||||
is_anonymous: Whether this is an anonymous user
|
||||
"""
|
||||
|
||||
# Standard JWT claims
|
||||
sub: str = Field(description="Subject (user UUID)")
|
||||
exp: Optional[int] = Field(default=None, description="Expiration timestamp (Unix)")
|
||||
iat: Optional[int] = Field(default=None, description="Issued at timestamp (Unix)")
|
||||
iss: Optional[str] = Field(default=None, description="Issuer")
|
||||
aud: str = Field(description="Audience (should be 'authenticated')")
|
||||
|
||||
# User identity claims
|
||||
email: Optional[str] = Field(default=None, description="User's email address")
|
||||
phone: Optional[str] = Field(default=None, description="User's phone number")
|
||||
email_confirmed_at: Optional[str] = Field(default=None, description="Email confirmation timestamp")
|
||||
phone_confirmed_at: Optional[str] = Field(default=None, description="Phone confirmation timestamp")
|
||||
|
||||
# Supabase metadata
|
||||
app_metadata: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Application-specific metadata (provider, role, etc.)"
|
||||
)
|
||||
user_metadata: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="User-specific metadata"
|
||||
)
|
||||
|
||||
# Supabase-specific claims
|
||||
role: Optional[str] = Field(default=None, description="Database role")
|
||||
aal: Optional[str] = Field(default=None, description="Authenticator assurance level")
|
||||
amr: Optional[list[dict[str, Any]]] = Field(default=None, description="Authentication methods reference")
|
||||
session_id: Optional[str] = Field(default=None, description="Session UUID")
|
||||
is_anonymous: Optional[bool] = Field(default=False, description="Whether user is anonymous")
|
||||
|
||||
class Config:
|
||||
"""Pydantic configuration."""
|
||||
extra = "allow" # Allow additional claims not explicitly defined
|
||||
|
||||
|
||||
class UserContext(BaseModel):
|
||||
"""Clean user context for API endpoints.
|
||||
|
||||
This model represents the authenticated user information that
|
||||
is passed to API endpoints via the get_current_user dependency.
|
||||
It contains only the essential information needed by most endpoints.
|
||||
|
||||
Attributes:
|
||||
id: User UUID from the 'sub' claim
|
||||
email: User's email address
|
||||
role: User's role (extracted from app_metadata or role claim)
|
||||
is_anonymous: Whether this is an anonymous user
|
||||
session_id: Current session ID
|
||||
"""
|
||||
|
||||
id: str = Field(description="User UUID")
|
||||
email: Optional[str] = Field(default=None, description="User's email address")
|
||||
role: str = Field(default="authenticated", description="User's role")
|
||||
is_anonymous: bool = Field(default=False, description="Whether user is anonymous")
|
||||
session_id: Optional[str] = Field(default=None, description="Current session ID")
|
||||
|
||||
@classmethod
|
||||
def from_token_payload(cls, payload: TokenPayload) -> "UserContext":
|
||||
"""Create UserContext from TokenPayload.
|
||||
|
||||
Extracts user information from the full JWT payload and creates
|
||||
a clean UserContext object. Role is extracted from app_metadata
|
||||
if available, otherwise defaults to the role claim or 'authenticated'.
|
||||
|
||||
Args:
|
||||
payload: Validated TokenPayload from JWT
|
||||
|
||||
Returns:
|
||||
UserContext with extracted user information
|
||||
"""
|
||||
# Extract role from app_metadata or fall back to role claim
|
||||
role = payload.app_metadata.get("role", payload.role or "authenticated")
|
||||
|
||||
return cls(
|
||||
id=payload.sub,
|
||||
email=payload.email,
|
||||
role=role,
|
||||
is_anonymous=payload.is_anonymous or False,
|
||||
session_id=payload.session_id,
|
||||
)
|
||||
|
||||
class Config:
|
||||
"""Pydantic configuration."""
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"email": "user@wealthwise.app",
|
||||
"role": "authenticated",
|
||||
"is_anonymous": False,
|
||||
"session_id": "session-uuid-here",
|
||||
}
|
||||
}
|
||||
92
backend/app/schemas/user.py
Normal file
92
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""User schemas for request and response validation.
|
||||
|
||||
This module defines Pydantic schemas for user-related operations including
|
||||
registration, authentication, and user information retrieval.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""Base user schema with common attributes."""
|
||||
|
||||
email: EmailStr = Field(description="User's email address")
|
||||
is_active: bool = Field(default=True, description="Whether account is active")
|
||||
is_superuser: bool = Field(default=False, description="Whether user is admin")
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""Schema for user registration.
|
||||
|
||||
Attributes:
|
||||
email: User's email address (must be unique)
|
||||
password: Plain text password (will be hashed)
|
||||
"""
|
||||
|
||||
email: EmailStr = Field(description="User's email address")
|
||||
password: str = Field(
|
||||
min_length=8,
|
||||
description="User's password (minimum 8 characters)",
|
||||
)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Schema for user updates.
|
||||
|
||||
All fields are optional to allow partial updates.
|
||||
"""
|
||||
|
||||
email: Optional[EmailStr] = Field(default=None, description="New email address")
|
||||
password: Optional[str] = Field(
|
||||
default=None,
|
||||
min_length=8,
|
||||
description="New password (minimum 8 characters)",
|
||||
)
|
||||
is_active: Optional[bool] = Field(default=None, description="Account status")
|
||||
|
||||
|
||||
class UserInDB(UserBase):
|
||||
"""Schema representing user as stored in database.
|
||||
|
||||
Includes the hashed password - should never be returned in API responses.
|
||||
"""
|
||||
|
||||
id: UUID
|
||||
hashed_password: str = Field(description="Bcrypt hashed password")
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserPublic(UserBase):
|
||||
"""Schema for user information returned in API responses.
|
||||
|
||||
Excludes sensitive fields like hashed_password.
|
||||
"""
|
||||
|
||||
id: UUID
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Schema for authentication token response."""
|
||||
|
||||
access_token: str = Field(description="JWT access token")
|
||||
token_type: str = Field(default="bearer", description="Token type")
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
"""Schema for JWT token payload."""
|
||||
|
||||
sub: Optional[str] = Field(default=None, description="Subject (user ID)")
|
||||
exp: Optional[datetime] = Field(default=None, description="Expiration time")
|
||||
type: Optional[str] = Field(default=None, description="Token type")
|
||||
54
backend/docker-compose.yml
Normal file
54
backend/docker-compose.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: wealthwise-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: wealthwise
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- wealthwise-network
|
||||
|
||||
# FastAPI Backend
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: wealthwise-backend
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/wealthwise
|
||||
SECRET_KEY: ${SECRET_KEY:-your-super-secret-key-change-in-production}
|
||||
ALGORITHM: HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: 30
|
||||
DEBUG: "false"
|
||||
PROJECT_NAME: WealthWise
|
||||
API_V1_STR: /api/v1
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
networks:
|
||||
- wealthwise-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
networks:
|
||||
wealthwise-network:
|
||||
driver: bridge
|
||||
2268
backend/poetry.lock
generated
Normal file
2268
backend/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
100
backend/pyproject.toml
Normal file
100
backend/pyproject.toml
Normal file
@@ -0,0 +1,100 @@
|
||||
[tool.poetry]
|
||||
name = "wealthwise-backend"
|
||||
version = "1.0.0"
|
||||
description = "Production-grade FastAPI backend for WealthWise financial analytics platform"
|
||||
authors = ["WealthWise Team <team@wealthwise.app>"]
|
||||
readme = "README.md"
|
||||
packages = [{include = "app"}]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.11"
|
||||
fastapi = "^0.109.0"
|
||||
uvicorn = {extras = ["standard"], version = "^0.27.0"}
|
||||
python-multipart = "^0.0.6"
|
||||
|
||||
# Database
|
||||
sqlmodel = "^0.0.14"
|
||||
asyncpg = "^0.29.0"
|
||||
alembic = "^1.13.0"
|
||||
|
||||
# Configuration & Validation
|
||||
pydantic = "^2.6.0"
|
||||
pydantic-settings = "^2.1.0"
|
||||
|
||||
# Auth
|
||||
python-jose = {extras = ["cryptography"], version = "^3.3.0"}
|
||||
bcrypt = "^4.2.0"
|
||||
|
||||
# Utilities
|
||||
python-dotenv = "^1.0.0"
|
||||
httpx = "^0.26.0"
|
||||
tenacity = "^8.2.3"
|
||||
|
||||
# Monitoring & Logging
|
||||
structlog = "^24.1.0"
|
||||
sentry-sdk = {extras = ["fastapi"], version = "^1.40.0"}
|
||||
greenlet = "^3.3.1"
|
||||
email-validator = "^2.3.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^8.0.0"
|
||||
pytest-asyncio = "^0.23.4"
|
||||
pytest-cov = "^4.1.0"
|
||||
httpx = "^0.26.0"
|
||||
black = "^24.1.0"
|
||||
isort = "^5.13.0"
|
||||
flake8 = "^7.0.0"
|
||||
mypy = "^1.8.0"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ["py311"]
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
line_length = 88
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
check_untyped_defs = true
|
||||
disallow_untyped_decorators = false
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
warn_unused_ignores = true
|
||||
warn_no_return = true
|
||||
warn_unreachable = true
|
||||
strict_equality = true
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
addopts = "-v --cov=app --cov-report=term-missing"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
branch = true
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"if self.debug:",
|
||||
"if settings.DEBUG",
|
||||
"raise AssertionError",
|
||||
"raise NotImplementedError",
|
||||
"if 0:",
|
||||
"if __name__ == .__main__.:",
|
||||
"class .*\\bProtocol\\):",
|
||||
"@(abc\\.)?abstractmethod",
|
||||
]
|
||||
25
backend/scripts/init_db.py
Normal file
25
backend/scripts/init_db.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Initialize database tables.
|
||||
|
||||
This script creates all database tables defined in the models.
|
||||
Run this before starting the application for the first time.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from app.core.config import get_settings
|
||||
from app.models.base import BaseModel
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
async def init_db():
|
||||
"""Create all database tables."""
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=True)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(BaseModel.metadata.create_all)
|
||||
|
||||
await engine.dispose()
|
||||
print("Database tables created successfully!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(init_db())
|
||||
57
backend/scripts/init_migrations.sh
Normal file
57
backend/scripts/init_migrations.sh
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
|
||||
# WealthWise Database Migration Script
|
||||
# This script initializes Alembic and creates the initial migration
|
||||
|
||||
echo "Setting up Alembic migrations for WealthWise..."
|
||||
|
||||
# Check if alembic is installed
|
||||
if ! command -v alembic &> /dev/null; then
|
||||
echo "Installing alembic..."
|
||||
pip install alembic
|
||||
fi
|
||||
|
||||
# Initialize Alembic if not already initialized
|
||||
if [ ! -d "alembic" ]; then
|
||||
echo "Initializing Alembic..."
|
||||
alembic init alembic
|
||||
|
||||
# Update alembic.ini with database URL
|
||||
sed -i '' 's|sqlalchemy.url = driver://user:pass@localhost/dbname|sqlalchemy.url = postgresql://postgres:postgres@localhost:5432/wealthwise|' alembic/alembic.ini 2>/dev/null || sed -i 's|sqlalchemy.url = driver://user:pass@localhost/dbname|sqlalchemy.url = postgresql://postgres:postgres@localhost:5432/wealthwise|' alembic/alembic.ini
|
||||
|
||||
echo "Alembic initialized successfully!"
|
||||
else
|
||||
echo "Alembic already initialized."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Update alembic/env.py to import your models (see instructions below)"
|
||||
echo "2. Run: alembic revision --autogenerate -m 'Initial migration'"
|
||||
echo "3. Run: alembic upgrade head"
|
||||
echo ""
|
||||
echo "=== alembic/env.py Configuration ==="
|
||||
echo "Add these imports to alembic/env.py:"
|
||||
echo ""
|
||||
echo "import asyncio"
|
||||
echo "from sqlalchemy.ext.asyncio import AsyncEngine"
|
||||
echo "from app.models import User, Portfolio, Transaction"
|
||||
echo "from app.models.base import BaseModel"
|
||||
echo ""
|
||||
echo "Then update run_migrations_online() to use async engine:"
|
||||
echo ""
|
||||
echo "def do_run_migrations(connection):"
|
||||
echo " context.configure(connection=connection, target_metadata=BaseModel.metadata)"
|
||||
echo " with context.begin_transaction():"
|
||||
echo " context.run_migrations()"
|
||||
echo ""
|
||||
echo "async def run_migrations_online():"
|
||||
echo " connectable = AsyncEngine(create_async_engine(config.get_main_option('sqlalchemy.url')))"
|
||||
echo " async with connectable.connect() as connection:"
|
||||
echo " await connection.run_sync(do_run_migrations)"
|
||||
echo " await connectable.dispose()"
|
||||
echo ""
|
||||
echo "if context.is_offline_mode():"
|
||||
echo " run_migrations_offline()"
|
||||
echo "else:"
|
||||
echo " asyncio.run(run_migrations_online())"
|
||||
195
backend/scripts/seed_db.py
Normal file
195
backend/scripts/seed_db.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Database seeding script for WealthWise.
|
||||
|
||||
This script populates the database with sample data for development and testing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.core.db import AsyncSessionLocal, engine
|
||||
from app.core.security import get_password_hash
|
||||
from app.models import Portfolio, Transaction, User
|
||||
from app.models.transaction import TransactionType
|
||||
|
||||
|
||||
async def seed_database():
|
||||
"""Seed the database with sample data."""
|
||||
print("Starting database seeding...")
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Check if demo user already exists
|
||||
result = await session.execute(
|
||||
select(User).where(User.email == "demo@wealthwise.app")
|
||||
)
|
||||
existing_user = result.scalar_one_or_none()
|
||||
|
||||
if existing_user:
|
||||
print("Demo user already exists. Skipping seeding.")
|
||||
return
|
||||
|
||||
# Create demo user
|
||||
print("Creating demo user...")
|
||||
demo_user = User(
|
||||
id=uuid4(),
|
||||
email="demo@wealthwise.app",
|
||||
hashed_password=get_password_hash("password123"),
|
||||
is_active=True,
|
||||
is_superuser=False,
|
||||
)
|
||||
session.add(demo_user)
|
||||
await session.flush() # Flush to get the user ID
|
||||
print(f"Created user: {demo_user.email} (ID: {demo_user.id})")
|
||||
|
||||
# Create portfolio
|
||||
print("Creating portfolio...")
|
||||
portfolio = Portfolio(
|
||||
id=uuid4(),
|
||||
user_id=demo_user.id,
|
||||
name="Main Investment",
|
||||
description="Primary investment portfolio for tracking stocks, mutual funds, and other assets.",
|
||||
)
|
||||
session.add(portfolio)
|
||||
await session.flush()
|
||||
print(f"Created portfolio: {portfolio.name} (ID: {portfolio.id})")
|
||||
|
||||
# Create sample transactions
|
||||
print("Creating transactions...")
|
||||
transactions = [
|
||||
# Income transactions
|
||||
Transaction(
|
||||
id=uuid4(),
|
||||
portfolio_id=portfolio.id,
|
||||
amount=Decimal("75000.00"),
|
||||
transaction_type=TransactionType.INCOME,
|
||||
transaction_date=date.today() - timedelta(days=30),
|
||||
description="Monthly Salary",
|
||||
category="Salary",
|
||||
),
|
||||
Transaction(
|
||||
id=uuid4(),
|
||||
portfolio_id=portfolio.id,
|
||||
amount=Decimal("2500.00"),
|
||||
transaction_type=TransactionType.INCOME,
|
||||
transaction_date=date.today() - timedelta(days=25),
|
||||
description="Freelance Project Payment",
|
||||
category="Freelance",
|
||||
),
|
||||
Transaction(
|
||||
id=uuid4(),
|
||||
portfolio_id=portfolio.id,
|
||||
amount=Decimal("1200.00"),
|
||||
transaction_type=TransactionType.INCOME,
|
||||
transaction_date=date.today() - timedelta(days=20),
|
||||
description="Stock Dividend - TCS",
|
||||
category="Dividend",
|
||||
),
|
||||
# Expense transactions
|
||||
Transaction(
|
||||
id=uuid4(),
|
||||
portfolio_id=portfolio.id,
|
||||
amount=Decimal("25000.00"),
|
||||
transaction_type=TransactionType.EXPENSE,
|
||||
transaction_date=date.today() - timedelta(days=28),
|
||||
description="Monthly Rent",
|
||||
category="Housing",
|
||||
),
|
||||
Transaction(
|
||||
id=uuid4(),
|
||||
portfolio_id=portfolio.id,
|
||||
amount=Decimal("8000.00"),
|
||||
transaction_type=TransactionType.EXPENSE,
|
||||
transaction_date=date.today() - timedelta(days=26),
|
||||
description="Grocery Shopping - BigBasket",
|
||||
category="Groceries",
|
||||
),
|
||||
Transaction(
|
||||
id=uuid4(),
|
||||
portfolio_id=portfolio.id,
|
||||
amount=Decimal("3500.00"),
|
||||
transaction_type=TransactionType.EXPENSE,
|
||||
transaction_date=date.today() - timedelta(days=22),
|
||||
description="Electricity & Internet Bill",
|
||||
category="Utilities",
|
||||
),
|
||||
Transaction(
|
||||
id=uuid4(),
|
||||
portfolio_id=portfolio.id,
|
||||
amount=Decimal("5000.00"),
|
||||
transaction_type=TransactionType.EXPENSE,
|
||||
transaction_date=date.today() - timedelta(days=18),
|
||||
description="Dining Out & Entertainment",
|
||||
category="Entertainment",
|
||||
),
|
||||
# Investment transactions
|
||||
Transaction(
|
||||
id=uuid4(),
|
||||
portfolio_id=portfolio.id,
|
||||
amount=Decimal("10000.00"),
|
||||
transaction_type=TransactionType.TRANSFER,
|
||||
transaction_date=date.today() - timedelta(days=24),
|
||||
description="Stock Purchase - Infosys",
|
||||
category="Investment",
|
||||
),
|
||||
Transaction(
|
||||
id=uuid4(),
|
||||
portfolio_id=portfolio.id,
|
||||
amount=Decimal("5000.00"),
|
||||
transaction_type=TransactionType.TRANSFER,
|
||||
transaction_date=date.today() - timedelta(days=21),
|
||||
description="Mutual Fund SIP - SBI Bluechip",
|
||||
category="Investment",
|
||||
),
|
||||
Transaction(
|
||||
id=uuid4(),
|
||||
portfolio_id=portfolio.id,
|
||||
amount=Decimal("3000.00"),
|
||||
transaction_type=TransactionType.TRANSFER,
|
||||
transaction_date=date.today() - timedelta(days=15),
|
||||
description="Crypto Purchase - Bitcoin",
|
||||
category="Investment",
|
||||
),
|
||||
]
|
||||
|
||||
for transaction in transactions:
|
||||
session.add(transaction)
|
||||
|
||||
await session.commit()
|
||||
print(f"Created {len(transactions)} transactions")
|
||||
|
||||
# Calculate summary
|
||||
income_total = sum(t.amount for t in transactions if t.transaction_type == TransactionType.INCOME)
|
||||
expense_total = sum(t.amount for t in transactions if t.transaction_type == TransactionType.EXPENSE)
|
||||
transfer_total = sum(t.amount for t in transactions if t.transaction_type == TransactionType.TRANSFER)
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("SEEDING COMPLETE!")
|
||||
print("="*50)
|
||||
print(f"User: {demo_user.email}")
|
||||
print(f"Password: password123")
|
||||
print(f"Portfolio: {portfolio.name}")
|
||||
print(f"\nTransaction Summary:")
|
||||
print(f" Total Income: ₹{income_total:,.2f}")
|
||||
print(f" Total Expenses: ₹{expense_total:,.2f}")
|
||||
print(f" Total Investments: ₹{transfer_total:,.2f}")
|
||||
print(f" Net Cash Flow: ₹{(income_total - expense_total):,.2f}")
|
||||
print("="*50)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point for seeding."""
|
||||
try:
|
||||
await seed_database()
|
||||
except Exception as e:
|
||||
print(f"Error seeding database: {e}")
|
||||
raise
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
1
backend/tests/__init__.py
Normal file
1
backend/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package for WealthWise Backend
|
||||
246
backend/tests/test_auth.py
Normal file
246
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Tests for authentication layer.
|
||||
|
||||
This module tests JWT validation and authentication dependencies.
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from jose import jwt
|
||||
|
||||
from app.api.deps import get_current_user, validate_supabase_token
|
||||
from app.core.config import get_settings
|
||||
from app.main import app
|
||||
from app.schemas.token import TokenPayload, UserContext
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class TestJWTValidation:
|
||||
"""Test cases for JWT token validation."""
|
||||
|
||||
def create_test_token(
|
||||
self,
|
||||
secret: str = None,
|
||||
expired: bool = False,
|
||||
wrong_audience: bool = False,
|
||||
missing_claims: bool = False,
|
||||
algorithm: str = "HS256",
|
||||
) -> str:
|
||||
"""Create a test JWT token with specified properties.
|
||||
|
||||
Args:
|
||||
secret: JWT signing secret (defaults to settings.SUPABASE_JWT_SECRET)
|
||||
expired: Whether the token should be expired
|
||||
wrong_audience: Whether to use wrong audience
|
||||
missing_claims: Whether to omit required claims
|
||||
algorithm: Signing algorithm
|
||||
|
||||
Returns:
|
||||
Encoded JWT string
|
||||
"""
|
||||
secret = secret or settings.SUPABASE_JWT_SECRET or settings.SECRET_KEY
|
||||
|
||||
# Base payload
|
||||
payload = {
|
||||
"sub": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"email": "test@wealthwise.app",
|
||||
"aud": "wrong-audience" if wrong_audience else "authenticated",
|
||||
"role": "authenticated",
|
||||
"app_metadata": {},
|
||||
"user_metadata": {},
|
||||
}
|
||||
|
||||
if not missing_claims:
|
||||
# Set expiration
|
||||
if expired:
|
||||
payload["exp"] = int(time.time()) - 3600 # 1 hour ago
|
||||
else:
|
||||
payload["exp"] = int(time.time()) + 3600 # 1 hour from now
|
||||
|
||||
return jwt.encode(payload, secret, algorithm=algorithm)
|
||||
|
||||
def test_validate_valid_token(self):
|
||||
"""Test validation of a valid JWT token."""
|
||||
token = self.create_test_token()
|
||||
|
||||
payload = validate_supabase_token(token)
|
||||
|
||||
assert isinstance(payload, TokenPayload)
|
||||
assert payload.sub == "550e8400-e29b-41d4-a716-446655440000"
|
||||
assert payload.email == "test@wealthwise.app"
|
||||
assert payload.aud == "authenticated"
|
||||
|
||||
def test_validate_expired_token(self):
|
||||
"""Test that expired tokens are rejected."""
|
||||
token = self.create_test_token(expired=True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_supabase_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "Could not validate credentials" in exc_info.value.detail
|
||||
|
||||
def test_validate_wrong_audience(self):
|
||||
"""Test that tokens with wrong audience are rejected."""
|
||||
token = self.create_test_token(wrong_audience=True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_supabase_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_validate_invalid_signature(self):
|
||||
"""Test that tokens with invalid signature are rejected."""
|
||||
token = self.create_test_token(secret="wrong-secret")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_supabase_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_validate_missing_exp(self):
|
||||
"""Test that tokens without expiration are rejected."""
|
||||
token = self.create_test_token(missing_claims=True)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
validate_supabase_token(token)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
class TestGetCurrentUser:
|
||||
"""Test cases for get_current_user dependency."""
|
||||
|
||||
def test_get_current_user_valid_token(self):
|
||||
"""Test that valid token returns UserContext."""
|
||||
# Create a valid token
|
||||
payload = {
|
||||
"sub": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"email": "user@wealthwise.app",
|
||||
"aud": "authenticated",
|
||||
"exp": int(time.time()) + 3600,
|
||||
"role": "authenticated",
|
||||
"app_metadata": {},
|
||||
"user_metadata": {},
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.SUPABASE_JWT_SECRET or settings.SECRET_KEY,
|
||||
algorithm="HS256",
|
||||
)
|
||||
|
||||
# Since get_current_user is async, we need to run it in an event loop
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
return await get_current_user(token)
|
||||
|
||||
user = asyncio.run(test())
|
||||
|
||||
assert isinstance(user, UserContext)
|
||||
assert user.id == "550e8400-e29b-41d4-a716-446655440000"
|
||||
assert user.email == "user@wealthwise.app"
|
||||
assert user.role == "authenticated"
|
||||
|
||||
def test_get_current_user_no_token(self):
|
||||
"""Test that missing token raises 401."""
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
return await get_current_user(None)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(test())
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
def test_get_current_user_invalid_token(self):
|
||||
"""Test that invalid token raises 401."""
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
return await get_current_user("invalid-token")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(test())
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
class TestUserContext:
|
||||
"""Test cases for UserContext model."""
|
||||
|
||||
def test_from_token_payload(self):
|
||||
"""Test conversion from TokenPayload to UserContext."""
|
||||
payload = TokenPayload(
|
||||
sub="550e8400-e29b-41d4-a716-446655440000",
|
||||
email="test@wealthwise.app",
|
||||
aud="authenticated",
|
||||
role="authenticated",
|
||||
app_metadata={"role": "admin"},
|
||||
)
|
||||
|
||||
context = UserContext.from_token_payload(payload)
|
||||
|
||||
assert context.id == "550e8400-e29b-41d4-a716-446655440000"
|
||||
assert context.email == "test@wealthwise.app"
|
||||
assert context.role == "admin" # From app_metadata
|
||||
|
||||
def test_from_token_payload_no_app_metadata_role(self):
|
||||
"""Test fallback to role claim when app_metadata has no role."""
|
||||
payload = TokenPayload(
|
||||
sub="550e8400-e29b-41d4-a716-446655440000",
|
||||
email="test@wealthwise.app",
|
||||
aud="authenticated",
|
||||
role="authenticated",
|
||||
app_metadata={},
|
||||
)
|
||||
|
||||
context = UserContext.from_token_payload(payload)
|
||||
|
||||
assert context.role == "authenticated" # From role claim
|
||||
|
||||
|
||||
class TestProtectedEndpoints:
|
||||
"""Integration tests for protected endpoints."""
|
||||
|
||||
def test_me_endpoint_without_auth(self):
|
||||
"""Test that /me endpoint requires authentication."""
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/api/v1/users/me")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_me_endpoint_with_valid_auth(self):
|
||||
"""Test that /me endpoint works with valid token."""
|
||||
# Create valid token
|
||||
payload = {
|
||||
"sub": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"email": "user@wealthwise.app",
|
||||
"aud": "authenticated",
|
||||
"exp": int(time.time()) + 3600,
|
||||
"role": "authenticated",
|
||||
"app_metadata": {},
|
||||
"user_metadata": {},
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.SUPABASE_JWT_SECRET or settings.SECRET_KEY,
|
||||
algorithm="HS256",
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get(
|
||||
"/api/v1/users/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == "550e8400-e29b-41d4-a716-446655440000"
|
||||
assert data["email"] == "user@wealthwise.app"
|
||||
193
backend/tests/test_auth_new.py
Normal file
193
backend/tests/test_auth_new.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""Tests for self-hosted authentication system.
|
||||
|
||||
This module tests the JWT-based authentication with OAuth2 Password Flow.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.core.security import create_access_token, get_password_hash, verify_password
|
||||
from app.main import app
|
||||
from app.models import User
|
||||
from app.schemas.user import UserCreate
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
class TestSecurityUtilities:
|
||||
"""Test cases for security utilities."""
|
||||
|
||||
def test_password_hashing(self):
|
||||
"""Test password hashing and verification."""
|
||||
password = "testpassword123"
|
||||
hashed = get_password_hash(password)
|
||||
|
||||
# Hash should be different from plain password
|
||||
assert hashed != password
|
||||
# Verification should succeed
|
||||
assert verify_password(password, hashed) is True
|
||||
# Wrong password should fail
|
||||
assert verify_password("wrongpassword", hashed) is False
|
||||
|
||||
def test_create_access_token(self):
|
||||
"""Test JWT token creation."""
|
||||
data = {"sub": "123e4567-e89b-12d3-a456-426614174000"}
|
||||
token = create_access_token(data)
|
||||
|
||||
assert token is not None
|
||||
assert isinstance(token, str)
|
||||
# JWT tokens have 3 parts separated by dots
|
||||
assert len(token.split(".")) == 3
|
||||
|
||||
|
||||
class TestAuthEndpoints:
|
||||
"""Test cases for authentication endpoints."""
|
||||
|
||||
def test_register_success(self):
|
||||
"""Test successful user registration."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "test@wealthwise.app",
|
||||
"password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["email"] == "test@wealthwise.app"
|
||||
assert "id" in data
|
||||
assert "hashed_password" not in data
|
||||
assert data["is_active"] is True
|
||||
|
||||
def test_register_duplicate_email(self):
|
||||
"""Test registration with duplicate email."""
|
||||
# First registration
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "duplicate@wealthwise.app",
|
||||
"password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
# Second registration with same email
|
||||
response = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "duplicate@wealthwise.app",
|
||||
"password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "email already registered" in response.json()["detail"].lower()
|
||||
|
||||
def test_login_success(self):
|
||||
"""Test successful login."""
|
||||
# Register first
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "login@wealthwise.app",
|
||||
"password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
# Login
|
||||
response = client.post(
|
||||
"/api/v1/auth/token",
|
||||
data={
|
||||
"username": "login@wealthwise.app",
|
||||
"password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
def test_login_invalid_credentials(self):
|
||||
"""Test login with invalid credentials."""
|
||||
response = client.post(
|
||||
"/api/v1/auth/token",
|
||||
data={
|
||||
"username": "nonexistent@wealthwise.app",
|
||||
"password": "wrongpassword",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_login_wrong_password(self):
|
||||
"""Test login with wrong password."""
|
||||
# Register first
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "wrongpass@wealthwise.app",
|
||||
"password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
# Login with wrong password
|
||||
response = client.post(
|
||||
"/api/v1/auth/token",
|
||||
data={
|
||||
"username": "wrongpass@wealthwise.app",
|
||||
"password": "wrongpassword",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestProtectedEndpoints:
|
||||
"""Test cases for protected endpoints."""
|
||||
|
||||
def test_me_without_auth(self):
|
||||
"""Test accessing /me without authentication."""
|
||||
response = client.get("/api/v1/users/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_me_with_valid_token(self):
|
||||
"""Test accessing /me with valid token."""
|
||||
# Register and login
|
||||
register_response = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "protected@wealthwise.app",
|
||||
"password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
login_response = client.post(
|
||||
"/api/v1/auth/token",
|
||||
data={
|
||||
"username": "protected@wealthwise.app",
|
||||
"password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
token = login_response.json()["access_token"]
|
||||
|
||||
# Access protected endpoint
|
||||
response = client.get(
|
||||
"/api/v1/users/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email"] == "protected@wealthwise.app"
|
||||
|
||||
def test_me_with_invalid_token(self):
|
||||
"""Test accessing /me with invalid token."""
|
||||
response = client.get(
|
||||
"/api/v1/users/me",
|
||||
headers={"Authorization": "Bearer invalid-token"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
55
backend/tests/test_config.py
Normal file
55
backend/tests/test_config.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Tests for core configuration module."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
|
||||
|
||||
class TestSettings:
|
||||
"""Test cases for Settings configuration."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test that default values are set correctly."""
|
||||
settings = Settings()
|
||||
|
||||
assert settings.PROJECT_NAME == "WealthWise"
|
||||
assert settings.API_V1_STR == "/api/v1"
|
||||
assert settings.VERSION == "1.0.0"
|
||||
assert settings.DB_POOL_SIZE == 20
|
||||
assert settings.DB_MAX_OVERFLOW == 10
|
||||
assert settings.DEBUG is False
|
||||
|
||||
def test_database_url_validation(self):
|
||||
"""Test that database URL is validated and converted to asyncpg."""
|
||||
# Test conversion from postgresql:// to postgresql+asyncpg://
|
||||
settings = Settings(DATABASE_URL="postgresql://user:pass@localhost:5432/db")
|
||||
assert settings.DATABASE_URL.startswith("postgresql+asyncpg://")
|
||||
|
||||
# Test that asyncpg URL is preserved
|
||||
asyncpg_url = "postgresql+asyncpg://user:pass@localhost:6543/postgres"
|
||||
settings2 = Settings(DATABASE_URL=asyncpg_url)
|
||||
assert settings2.DATABASE_URL == asyncpg_url
|
||||
|
||||
def test_cors_origins_parsing(self):
|
||||
"""Test CORS origins parsing from string."""
|
||||
settings = Settings(CORS_ORIGINS="http://localhost:5173,https://example.com")
|
||||
assert "http://localhost:5173" in settings.CORS_ORIGINS
|
||||
assert "https://example.com" in settings.CORS_ORIGINS
|
||||
|
||||
# Test list input
|
||||
settings2 = Settings(CORS_ORIGINS=["http://localhost:3000"])
|
||||
assert settings2.CORS_ORIGINS == ["http://localhost:3000"]
|
||||
|
||||
|
||||
class TestGetSettings:
|
||||
"""Test cases for get_settings function."""
|
||||
|
||||
def test_get_settings_returns_singleton(self):
|
||||
"""Test that get_settings returns a cached singleton."""
|
||||
settings1 = get_settings()
|
||||
settings2 = get_settings()
|
||||
|
||||
# Should be the same object (cached)
|
||||
assert settings1 is settings2
|
||||
40
backend/tests/test_health.py
Normal file
40
backend/tests/test_health.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Tests for health check endpoint."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
class TestHealthCheck:
|
||||
"""Test cases for health check endpoint."""
|
||||
|
||||
def test_health_endpoint_exists(self):
|
||||
"""Test that health endpoint is accessible."""
|
||||
response = client.get("/api/v1/health")
|
||||
assert response.status_code in [200, 503] # 200 if DB connected, 503 if not
|
||||
|
||||
def test_health_response_structure(self):
|
||||
"""Test that health response has expected structure."""
|
||||
response = client.get("/api/v1/health")
|
||||
data = response.json()
|
||||
|
||||
assert "status" in data
|
||||
assert "database" in data
|
||||
assert "version" in data
|
||||
|
||||
def test_readiness_probe(self):
|
||||
"""Test readiness probe endpoint."""
|
||||
response = client.get("/api/v1/health/ready")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ready": True}
|
||||
|
||||
def test_liveness_probe(self):
|
||||
"""Test liveness probe endpoint."""
|
||||
response = client.get("/api/v1/health/live")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"alive": True}
|
||||
Reference in New Issue
Block a user