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
12 changes: 12 additions & 0 deletions API/models/expense.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,15 @@ class Expense(Base):
name="chk_expenses_one_fk"
),
)

@property
def split_amount(self) -> float:
"""
Calculates amount per person if it is a group expense.
"""
print(f"DEBUG: Group {self.group_id} has members: {[u.id for u in self.group.users]}")
if self.group_id and self.group and self.group.users:
member_count = len(self.group.users)
if member_count > 0:
return round(self.amount / member_count, 2)
return self.amount
19 changes: 12 additions & 7 deletions API/repositories/expense_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

from models.category import Category
from models.expense import Expense
from models.group import Group
from models.user_group import UserGroup
from sqlalchemy import and_, asc, desc, or_, select
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload


class IExpenseRepository(ABC):
Expand Down Expand Up @@ -92,7 +93,7 @@ def get_by_id(self, expense_id: int) -> Optional[Expense]:
"""
statement = select(Expense).where(Expense.id == expense_id)

return self.db.scalars(statement).first()
return self.db.scalars(statement).unique().first()

def get_all(
self,
Expand Down Expand Up @@ -127,12 +128,14 @@ def get_all(
if category_ids:
conditions.append(Expense.category_id.in_(category_ids))

statement = select(Expense)
statement = select(Expense).options(
joinedload(Expense.group).joinedload(Group.users)
)
if conditions:
statement = statement.where(and_(*conditions))

statement = statement.order_by(sort_order).offset(offset).limit(limit)

return list(self.db.scalars(statement))
return list(self.db.scalars(statement).unique())

def get_by_user(
self,
Expand Down Expand Up @@ -187,13 +190,14 @@ def get_by_user(

statement = (
select(Expense)
.options(joinedload(Expense.group).joinedload(Group.users))
.where(and_(*conditions))
.order_by(sort_order)
.offset(offset)
.limit(limit)
)

return list(self.db.scalars(statement))
return list(self.db.scalars(statement).unique())

def get_by_group(
self,
Expand Down Expand Up @@ -231,13 +235,14 @@ def get_by_group(

statement = (
select(Expense)
.options(joinedload(Expense.group).joinedload(Group.users))
.where(and_(*conditions))
.order_by(sort_order)
.offset(offset)
.limit(limit)
)

return list(self.db.scalars(statement))
return list(self.db.scalars(statement).unique())

def update(self, expense_id: int, fields: dict) -> int:
"""
Expand Down
13 changes: 13 additions & 0 deletions API/schemas/expense.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,18 @@ class ExpenseResponse(ExpenseBase):
created_at: datetime
description: Optional[str] = None

split_amount: float

class Config:
from_attributes = True

class Expense(ExpenseBase):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

asta nu stiu daca mai trebuia sau daca sa folosim ExpenseBase, mi se pare ca is prea multe modele dar nu stiu exact cum ar trebui

id: int
user_id: Optional[int] = None
group_id: Optional[int] = None
created_at: datetime

split_amount: float

class Config:
from_attributes = True
28 changes: 15 additions & 13 deletions API/services/user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,24 +208,26 @@ def delete_user(self, user_id: int) -> APIResponse:
success=True
)

def change_password(self, user_id: int, old_password: str, new_password: str) -> APIResponse:
def change_password(self, user_id: int, old_password: str, new_password: str) -> dict:
"""
Verifies the old password and updates to the new password.
Allows an authenticated user to change their password by providing the old one.
"""
self.logger.info(f"Changing password for user_id={user_id}")

user = self._validate_user(user_id=user_id)
if not PasswordUtil.verify_password(old_password, user.hashed_password):
self.logger.warning(f"Password change failed for user_id={user_id}: Invalid old password")
raise HTTPException(status_code=STATUS_BAD_REQUEST, detail="Invalid old password.")
self.logger.info(f"Password change requested for user_id={user_id}")
user = self.repository.get_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found.")

new_hashed_password = PasswordUtil.hash_password(new_password)
# Verify old password
if not PasswordUtil.verify_password(old_password, user.hashed_password):
self.logger.warning(f"Invalid old password provided for user_id={user_id}")
raise HTTPException(status_code=400, detail="Invalid old password.")

self.repository.update(user_id, {HASHED_PASSWORD_FIELD: new_hashed_password})
# Update with new password
new_hashed = PasswordUtil.hash_password(new_password)
self.repository.update(user_id, {"hashed_password": new_hashed})

return APIResponse(
success=True,
)
self.logger.info(f"Password successfully changed for user_id={user_id}")
return {"message": "Password changed successfully."}

def request_password_reset(self, email: str) -> APIResponse:
"""
Expand Down
43 changes: 29 additions & 14 deletions API/tests/test_user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,7 @@ def user_service():
mock_repo = MockUserRepository()
service = UserService.__new__(UserService) # bypass __init__
service.repository = mock_repo
# Initialize logger mock if needed, or rely on the fact that Logger() is static/singleton
# For this test structure, assuming Logger calls are safe or mocked elsewhere isn't strictly necessary
# if the real Logger doesn't break tests.
# However, strictly speaking, we might want to silence the logger here.
# Mock the logger to prevent errors during tests
service.logger = type('MockLogger', (), {'debug': lambda s: None, 'info': lambda s: None, 'warning': lambda s: None, 'error': lambda s: None})
return service

Expand All @@ -78,8 +75,16 @@ def test_register_user_success(user_service):
phone_number="123456"
)
result = user_service.register_user(user_in)

assert "access_token" in result
assert result["user"].email == "test@example.com"

# REPAIR: result["user"] is an ID (int), not an object
user_id = result["user"]
assert isinstance(user_id, int)

# Fetch object to verify properties
created_user = user_service.repository.get_by_id(user_id)
assert created_user.email == "test@example.com"


def test_register_duplicate_email(user_service):
Expand Down Expand Up @@ -110,7 +115,9 @@ def test_login_success(user_service):

login_in = UserLogin(email="login@example.com", password="Password123")
result = user_service.login_user(login_in)

assert "access_token" in result
# Login service DOES return the User object (unlike register)
assert result["user"].email == "login@example.com"


Expand Down Expand Up @@ -140,7 +147,9 @@ def test_get_user_by_id_success(user_service):
)
result = user_service.register_user(user_in)

user_id = result["user"].id
# REPAIR: Extract ID
user_id = result["user"]

user = user_service.get_user_by_id(user_id)
assert user.id == user_id
assert user.email == "getuser@example.com"
Expand Down Expand Up @@ -174,7 +183,9 @@ def test_update_user_success(user_service):
phone_number="123456"
)
result = user_service.register_user(user_in)
user_id = result["user"].id

# REPAIR: Extract ID
user_id = result["user"]

update_in = UserUpdate(first_name="NewName")

Expand All @@ -190,7 +201,8 @@ def test_update_user_email_conflict(user_service):
last_name="A",
phone_number="123",
))
u1_id = u1_result["user"].id
# REPAIR: Extract ID
u1_id = u1_result["user"]

user_service.register_user(UserCreate(
email="b@example.com",
Expand All @@ -214,20 +226,21 @@ def test_delete_user(user_service):
last_name="User",
phone_number="000",
))
user_id = r["user"].id
# REPAIR: Extract ID
user_id = r["user"]

user_service.delete_user(user_id)

with pytest.raises(HTTPException):
user_service.get_user_by_id(user_id)


def test_request_password_reset_nonexistent(user_service, capsys):
def test_request_password_reset_nonexistent(user_service):
result = user_service.request_password_reset("none@example.com")
assert "Check the API console" in result["message"]


def test_request_password_reset_success(user_service, capsys):
def test_request_password_reset_success(user_service):
user_service.register_user(UserCreate(
email="reset@example.com",
password="Password123",
Expand All @@ -248,7 +261,8 @@ def test_reset_password_success(user_service):
last_name="B",
phone_number="123",
))
user_id = reg["user"].id
# REPAIR: Extract ID
user_id = reg["user"]

token = user_service._create_reset_token(user_id)

Expand Down Expand Up @@ -331,7 +345,8 @@ def test_change_password_success(user_service):
phone_number="111"
)
res = user_service.register_user(reg_data)
user_id = res["user"].id
# NOTE: register_user now returns an ID in 'user', not the object.
user_id = res["user"]

# 2. Change Password
user_service.change_password(user_id, "OldPassword123", "NewPassword999")
Expand Down Expand Up @@ -360,7 +375,7 @@ def test_change_password_invalid_old_password(user_service):
phone_number="222"
)
res = user_service.register_user(reg_data)
user_id = res["user"].id
user_id = res["user"]

# 2. Attempt change with WRONG old password
with pytest.raises(HTTPException) as exc:
Expand Down
Loading