Initial commit: WealthWise financial analytics platform

This commit is contained in:
2026-02-14 21:16:57 +05:30
commit b8588df583
171 changed files with 29048 additions and 0 deletions

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# WealthWise Backend Application

View File

@@ -0,0 +1 @@
# API package

153
backend/app/api/deps.py Normal file
View 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",
]

View File

@@ -0,0 +1 @@
# API v1 endpoints

38
backend/app/api/v1/api.py Normal file
View 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"])

View File

@@ -0,0 +1 @@
# API v1 endpoints package

View 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")

View 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}

View 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

View File

@@ -0,0 +1 @@
# Core package - configuration and database

129
backend/app/core/config.py Normal file
View 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
View 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,
)

View 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
View 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()

View 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",
]

View 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,
},
)

View 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

View 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

View 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

View 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",
]

View 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",
}
}

View 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")