Initial commit: WealthWise financial analytics platform
This commit is contained in:
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")
|
||||
Reference in New Issue
Block a user