Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,23 +1,21 @@
# Mac
.DS_Store

# Application
# Project management
.my_project/
.cursor/
real_data/
my_notes.txt
notes.txt

# Application
*log.*
.vite/
notes/

# Frontend
frontend/coverage/
.vite/

# CSV files - ignore purchases.csv but keep sample data
*-purchases.csv

# Cursor
.cursor/

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
Expand Down
2 changes: 2 additions & 0 deletions backend/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[pytest]
pythonpath = src
38 changes: 22 additions & 16 deletions backend/src/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def get_users(db: Session, skip: int = 0, limit: int = 100) -> List[User]:

def create_user(db: Session, user: UserCreate) -> User:
# Sanitize input
user_data = user.dict()
user_data = user.model_dump()
user_data['username'] = sanitize_input(user_data['username'])
user_data['email'] = sanitize_input(user_data['email'])

Expand All @@ -90,7 +90,7 @@ def create_user(db: Session, user: UserCreate) -> User:
def update_user(db: Session, user_id: UUID, user: UserUpdate) -> Optional[User]:
db_user = get_user(db, user_id)
if db_user:
update_data = user.dict(exclude_unset=True)
update_data = user.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_user, field, value)
db.commit()
Expand Down Expand Up @@ -134,7 +134,7 @@ def get_or_create_category(db: Session, category_name: str) -> Category:

def create_category(db: Session, category: CategoryCreate) -> Category:
# Sanitize input
category_data = category.dict()
category_data = category.model_dump()
category_data['category_name'] = sanitize_input(category_data['category_name'])

db_category = Category(**category_data)
Expand All @@ -146,7 +146,7 @@ def create_category(db: Session, category: CategoryCreate) -> Category:
def update_category(db: Session, category_id: UUID, category: CategoryUpdate) -> Optional[Category]:
db_category = get_category(db, category_id)
if db_category:
update_data = category.dict(exclude_unset=True)
update_data = category.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_category, field, value)
db.commit()
Expand Down Expand Up @@ -181,7 +181,7 @@ def get_expenses(

def create_expense(db: Session, expense: ExpenseCreate) -> Expense:
# Sanitize input
expense_data = expense.dict()
expense_data = expense.model_dump()
expense_data['item'] = sanitize_input(expense_data['item'])
expense_data['vendor'] = sanitize_input(expense_data['vendor'])
if expense_data.get('payment_method'):
Expand Down Expand Up @@ -214,7 +214,7 @@ def create_expense(db: Session, expense: ExpenseCreate) -> Expense:
def update_expense(db: Session, expense_id: UUID, expense: ExpenseUpdate) -> Optional[Expense]:
db_expense = get_expense(db, expense_id)
if db_expense:
update_data = expense.dict(exclude_unset=True)
update_data = expense.model_dump(exclude_unset=True)

# Extract new categories before updating expense
new_categories = update_data.pop('new_categories', []) or []
Expand Down Expand Up @@ -335,7 +335,7 @@ def get_wishlist_items(
return query.order_by(Wishlist.priority).offset(skip).limit(limit).all()

def create_wishlist_item(db: Session, wishlist_item: WishlistCreate) -> Wishlist:
db_wishlist_item = Wishlist(**wishlist_item.dict())
db_wishlist_item = Wishlist(**wishlist_item.model_dump())
db.add(db_wishlist_item)
db.commit()
db.refresh(db_wishlist_item)
Expand All @@ -344,7 +344,7 @@ def create_wishlist_item(db: Session, wishlist_item: WishlistCreate) -> Wishlist
def update_wishlist_item(db: Session, wish_id: UUID, wishlist_item: WishlistUpdate) -> Optional[Wishlist]:
db_wishlist_item = get_wishlist_item(db, wish_id)
if db_wishlist_item:
update_data = wishlist_item.dict(exclude_unset=True)
update_data = wishlist_item.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_wishlist_item, field, value)
db.commit()
Expand Down Expand Up @@ -397,7 +397,7 @@ def create_budget(db: Session, budget: BudgetCreate) -> Budget:
is_over_max = total_spend > budget.max_spend

# Create budget with calculated values
budget_data = budget.dict()
budget_data = budget.model_dump()
budget_data['current_spend'] = current_spend
budget_data['future_spend'] = future_spend
budget_data['is_over_max'] = is_over_max
Expand All @@ -413,7 +413,7 @@ def create_budget(db: Session, budget: BudgetCreate) -> Budget:
def update_budget(db: Session, budget_id: UUID, budget: BudgetUpdate) -> Optional[Budget]:
db_budget = get_budget(db, budget_id)
if db_budget:
update_data = budget.dict(exclude_unset=True)
update_data = budget.model_dump(exclude_unset=True)

# Recalculate current spend if category or user changed
if 'category_id' in update_data or 'user_id' in update_data:
Expand Down Expand Up @@ -469,12 +469,18 @@ def get_total_expenses_in_date_range(db: Session, user_id: Optional[UUID] = None
return result or 0.0

def get_expenses_by_category(db: Session, user_id: Optional[UUID] = None) -> List[dict]:
query = db.query(
Category.category_name,
func.sum(Expense.price).label('total')
).join(ExpenseCategory).join(Expense)

"""Aggregate expenses by category with explicit join path to avoid ambiguity."""
query = (
db.query(
Category.category_name,
func.sum(Expense.price).label('total')
)
.select_from(Category)
.join(ExpenseCategory, ExpenseCategory.category_id == Category.category_id)
.join(Expense, Expense.expense_id == ExpenseCategory.expense_id)
)

if user_id:
query = query.filter(Expense.user_id == user_id)

return query.group_by(Category.category_id, Category.category_name).all()
3 changes: 1 addition & 2 deletions backend/src/database.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import sessionmaker, declarative_base
import os

# Database URL from environment variable
Expand Down
6 changes: 3 additions & 3 deletions backend/src/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class Category(Base):
# Relationships
expense_categories = relationship("ExpenseCategory", back_populates="category")
budgets = relationship("Budget", back_populates="category")
expenses = relationship("Expense", secondary="expense_categories", back_populates="categories")
expenses = relationship("Expense", secondary="expense_categories", back_populates="categories", overlaps="expense_categories")

class Expense(Base):
__tablename__ = "expenses"
Expand All @@ -47,8 +47,8 @@ class Expense(Base):

# Relationships
user = relationship("User", back_populates="expenses")
expense_categories = relationship("ExpenseCategory", back_populates="expense")
categories = relationship("Category", secondary="expense_categories", back_populates="expenses")
expense_categories = relationship("ExpenseCategory", back_populates="expense", overlaps="expenses")
categories = relationship("Category", secondary="expense_categories", back_populates="expenses", overlaps="expense_categories")

class ExpenseCategory(Base):
__tablename__ = "expense_categories"
Expand Down
78 changes: 37 additions & 41 deletions backend/src/schemas.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from pydantic import BaseModel, Field, validator, EmailStr
from pydantic import field_validator, ConfigDict, BaseModel, Field, validator, EmailStr
from typing import Optional, List
from datetime import datetime, date
from uuid import UUID
Expand All @@ -8,30 +8,29 @@
class UserBase(BaseModel):
username: str = Field(..., min_length=3, max_length=50, pattern=r'^[a-zA-Z0-9_]+$')
email: EmailStr
role: str = Field(..., pattern=r'^(regular|admin)$', strip_whitespace=True)
role: str = Field(..., pattern=r'^(regular|admin)$', json_schema_extra={'strip_whitespace': True})

class UserCreate(UserBase):
password_hash: str = Field(..., min_length=60, max_length=73) # bcrypt hash length

class UserUpdate(UserBase):
username: Optional[str] = Field(None, min_length=3, max_length=50, pattern=r'^[a-zA-Z0-9_]+$')
email: Optional[EmailStr] = None
role: Optional[str] = Field(None, pattern=r'^(regular|admin)$', strip_whitespace=True)
role: Optional[str] = Field(None, pattern=r'^(regular|admin)$', json_schema_extra={'strip_whitespace': True})
password_hash: Optional[str] = Field(None, min_length=60, max_length=73)

class User(UserBase):
user_id: UUID
created_at: datetime
last_login: datetime

class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)

# Category schemas
class CategoryBase(BaseModel):
category_name: str = Field(..., min_length=1, max_length=100, strip_whitespace=True)
category_name: str = Field(..., min_length=1, max_length=100, json_schema_extra={'strip_whitespace': True})

@validator('category_name')
@field_validator('category_name')
@classmethod
def validate_category_name(cls, v):
# Remove any potentially dangerous characters
v = re.sub(r'[<>"\']', '', v)
Expand All @@ -41,24 +40,23 @@ class CategoryCreate(CategoryBase):
pass

class CategoryUpdate(CategoryBase):
category_name: Optional[str] = Field(None, min_length=1, max_length=100, strip_whitespace=True)
category_name: Optional[str] = Field(None, min_length=1, max_length=100, json_schema_extra={'strip_whitespace': True})

class Category(CategoryBase):
category_id: UUID

class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)

# Expense schemas
class ExpenseBase(BaseModel):
item: str = Field(..., min_length=1, max_length=255, strip_whitespace=True)
vendor: str = Field(..., min_length=1, max_length=255, strip_whitespace=True)
item: str = Field(..., min_length=1, max_length=255, json_schema_extra={'strip_whitespace': True})
vendor: str = Field(..., min_length=1, max_length=255, json_schema_extra={'strip_whitespace': True})
price: float = Field(..., gt=0, le=999999.99) # Positive price with reasonable limit
date_purchased: date
payment_method: Optional[str] = Field(None, max_length=100, strip_whitespace=True)
notes: Optional[str] = Field(None, max_length=1000, strip_whitespace=True)
payment_method: Optional[str] = Field(None, max_length=100, json_schema_extra={'strip_whitespace': True})
notes: Optional[str] = Field(None, max_length=1000, json_schema_extra={'strip_whitespace': True})

@validator('item', 'vendor', 'payment_method', 'notes')
@field_validator('item', 'vendor', 'payment_method', 'notes')
@classmethod
def validate_text_fields(cls, v):
if v is not None:
# Remove potentially dangerous characters
Expand All @@ -71,22 +69,20 @@ class ExpenseCreate(ExpenseBase):
new_categories: Optional[List[str]] = Field(None, description="List of new category names to create")

class ExpenseUpdate(ExpenseBase):
item: Optional[str] = Field(None, min_length=1, max_length=255, strip_whitespace=True)
vendor: Optional[str] = Field(None, min_length=1, max_length=255, strip_whitespace=True)
item: Optional[str] = Field(None, min_length=1, max_length=255, json_schema_extra={'strip_whitespace': True})
vendor: Optional[str] = Field(None, min_length=1, max_length=255, json_schema_extra={'strip_whitespace': True})
price: Optional[float] = Field(None, gt=0, le=999999.99)
date_purchased: Optional[date] = None
payment_method: Optional[str] = Field(None, max_length=100, strip_whitespace=True)
notes: Optional[str] = Field(None, max_length=1000, strip_whitespace=True)
payment_method: Optional[str] = Field(None, max_length=100, json_schema_extra={'strip_whitespace': True})
notes: Optional[str] = Field(None, max_length=1000, json_schema_extra={'strip_whitespace': True})
new_categories: Optional[List[str]] = Field(None, description="List of new category names to create")

class Expense(ExpenseBase):
expense_id: UUID
user_id: UUID
created_at: datetime
categories: Optional[List[Category]] = []

class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)

# ExpenseCategory schemas
class ExpenseCategoryBase(BaseModel):
Expand All @@ -97,20 +93,20 @@ class ExpenseCategoryCreate(ExpenseCategoryBase):
pass

class ExpenseCategory(ExpenseCategoryBase):
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)

# Wishlist schemas
class WishlistBase(BaseModel):
item: str = Field(..., min_length=1, max_length=255, strip_whitespace=True)
vendor: Optional[str] = Field(None, max_length=255, strip_whitespace=True)
item: str = Field(..., min_length=1, max_length=255, json_schema_extra={'strip_whitespace': True})
vendor: Optional[str] = Field(None, max_length=255, json_schema_extra={'strip_whitespace': True})
price: float = Field(..., gt=0, le=999999.99)
priority: int = Field(..., ge=1, le=10) # Priority 1-10
status: str = Field(..., pattern=r'^(wished|scheduled|bought)$')
notes: Optional[str] = Field(None, max_length=1000, strip_whitespace=True)
notes: Optional[str] = Field(None, max_length=1000, json_schema_extra={'strip_whitespace': True})
planned_date: Optional[date] = None

@validator('item', 'vendor', 'notes')
@field_validator('item', 'vendor', 'notes')
@classmethod
def validate_text_fields(cls, v):
if v is not None:
v = re.sub(r'[<>"\']', '', v)
Expand All @@ -121,22 +117,20 @@ class WishlistCreate(WishlistBase):
user_id: UUID

class WishlistUpdate(WishlistBase):
item: Optional[str] = Field(None, min_length=1, max_length=255, strip_whitespace=True)
vendor: Optional[str] = Field(None, max_length=255, strip_whitespace=True)
item: Optional[str] = Field(None, min_length=1, max_length=255, json_schema_extra={'strip_whitespace': True})
vendor: Optional[str] = Field(None, max_length=255, json_schema_extra={'strip_whitespace': True})
price: Optional[float] = Field(None, gt=0, le=999999.99)
priority: Optional[int] = Field(None, ge=1, le=10)
status: Optional[str] = Field(None, pattern=r'^(wished|scheduled|bought)$')
notes: Optional[str] = Field(None, max_length=1000, strip_whitespace=True)
notes: Optional[str] = Field(None, max_length=1000, json_schema_extra={'strip_whitespace': True})
planned_date: Optional[date] = None

class Wishlist(WishlistBase):
wish_id: UUID
user_id: UUID
created_at: Optional[datetime] = None
user: Optional[User] = None

class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)

# Budget schemas
class BudgetBase(BaseModel):
Expand All @@ -148,13 +142,17 @@ class BudgetBase(BaseModel):
timeframe_interval: Optional[int] = Field(None, ge=1, le=100)
recurring_start_date: Optional[date] = None

@validator('end_date')
# TODO[pydantic]: We couldn't refactor the `validator`, please replace it by `field_validator` manually.
# Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-validators for more information.
@field_validator('end_date')
def validate_date_range(cls, v, values):
if 'start_date' in values and v <= values['start_date']:
raise ValueError('end_date must be after start_date')
return v

@validator('timeframe_interval')
# TODO[pydantic]: We couldn't refactor the `validator`, please replace it by `field_validator` manually.
# Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-validators for more information.
@field_validator('timeframe_interval')
def validate_timeframe_interval(cls, v, values):
if 'timeframe_type' in values and values['timeframe_type'] != 'custom' and v is None:
raise ValueError('timeframe_interval is required for non-custom timeframes')
Expand All @@ -181,6 +179,4 @@ class Budget(BudgetBase):
category_id: UUID
current_spend: float = Field(..., ge=0, le=999999.99)
future_spend: float = Field(..., ge=0, le=999999.99)

class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
Loading