Skip to content
Merged
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: 14 additions & 0 deletions API/dependencies/di.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ def get_expense_service(
def get_group_service(repo: IGroupRepository = Depends(get_group_repository)) -> IGroupService:
return GroupService(repo)

def get_group_log_repository(db: Session = Depends(get_db)) -> IGroupLogRepository:
return GroupLogRepository(db)

def get_user_group_service(
user_group_repo: IUserGroupRepository = Depends(get_user_group_repository),
group_repo: IGroupRepository = Depends(get_group_repository),
Expand All @@ -75,6 +78,17 @@ def get_group_log_service(
) -> IGroupLogService:
return GroupLogService(group_log_repo, group_repo, user_repo)

def get_user_group_service(
user_group_repo: IUserGroupRepository = Depends(get_user_group_repository),
group_repo: IGroupRepository = Depends(get_group_repository),
user_repo: IUserRepository = Depends(get_user_repository),
log_repo: IGroupLogRepository = Depends(get_group_log_repository),
) -> IUserGroupService:
return UserGroupService(user_group_repo, group_repo, user_repo, log_repo)

def get_expense_payment_repository(db: Session = Depends(get_db)) -> IExpensePaymentRepository:
return ExpensePaymentRepository(db)

def get_expense_payment_service(
repo: IExpensePaymentRepository = Depends(get_expense_payment_repository),
expense_repository: IExpenseRepository = Depends(get_expense_repository),
Expand Down
7 changes: 3 additions & 4 deletions API/repositories/category_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ def add(self, category: Category) -> int: ...
@abstractmethod
def get_all(self, sort_by: str, order: str) -> List[Category]: ...

@abstractmethod
def get_by_id(self, category_id: int) -> Category: ...

@abstractmethod
def update(self, category_id: int, fields: dict) -> int: ...

Expand All @@ -33,7 +30,9 @@ def add(self, category: Category) -> int:
return category.id

def get_by_title_or_keywords(self, user_id: int, title: str, keywords: list[str]) -> bool:
statement = (select(Category.id).where(Category.user_id == user_id,or_(
statement = (select(Category.id).where(
Category.user_id == user_id,
or_(
Category.title == title,
Category.keywords.op("&&")(cast(keywords, ARRAY(Text))))))
return self.db.scalar(statement) is not None
Expand Down
2 changes: 1 addition & 1 deletion API/routes/category_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def get_all_categories(
def update_category(category_id: int, category_in: CategoryUpdate, requester_id: int = Depends(get_current_user_id), category_service: ICategoryService = Depends(get_category_service)):
return category_service.update_category(category_id, category_in, requester_id)

@router.delete("/{category_id")
@router.delete("/{category_id}")
def delete_category(category_id: int, requester_id: int = Depends(get_current_user_id), category_service: ICategoryService = Depends(get_category_service)):
return category_service.delete_category(category_id, requester_id)

Expand Down
6 changes: 5 additions & 1 deletion API/routes/user_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ def delete_user(user_id: int, _ = Depends(get_current_user_id), user_service: IU


@router.post("/join-group/{invitation_code}")
def join_group_with_invitation_code(invitation_code: str, user_id: int = Depends(get_current_user_id), user_group_service: IUserGroupService = Depends(get_user_group_service)):
def join_group_with_invitation_code(
invitation_code: str,
user_id: int = Depends(get_current_user_id),
user_group_service: IUserGroupService = Depends(get_user_group_service)
):
"""
Allows the authenticated user to join a group using an invitation code.
"""
Expand Down
7 changes: 3 additions & 4 deletions API/services/expense_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ def create_expense(self, data: ExpenseCreate, user_id: int) -> APIResponse:
if data.group_id is not None:
self._validate_group(data.group_id)
self._validate_category(data.category_id, user_id)

id = self.repository.add(expense)

return APIResponse(
Expand Down Expand Up @@ -181,11 +181,10 @@ def get_group_expenses(self, group_id: int, *args, **kwargs) -> APIResponse:
Method for returning group expenses
"""
self._validate_group(group_id)

expenses = self.repository.get_by_group(group_id, *args, **kwargs)

expenses_response = [ExpenseResponse.model_validate(expense) for expense in expenses]

return APIResponse(
success=True,
data=expenses_response
Expand All @@ -197,7 +196,7 @@ def update_expense(self, expense_id: int, data: ExpenseUpdate, requester_id: int
"""
self._validate_owner(expense_id, requester_id)
self._validate_category(data.category_id, requester_id)

fields = data.model_dump(exclude_unset=True)
fields.pop("user_id", None)

Expand Down
6 changes: 5 additions & 1 deletion API/services/group_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from schemas.group import GroupCreate, GroupResponse, GroupUpdate
from utils.helpers.constants import ID_FIELD, STATUS_BAD_REQUEST, STATUS_NOT_FOUND
from utils.helpers.generate_invitation_code import generate_invitation_code
import base64


class IGroupService(ABC):
Expand Down Expand Up @@ -153,7 +154,10 @@ def generate_invite_qr(self, group_id: int) -> APIResponse:
img.save(buffer, format="PNG")
buffer.seek(0)

# fastapi is throwing error when returning bytes directly, so encode it to base64
b64_bytes = base64.b64encode(buffer.read()).decode("utf-8")

return APIResponse(
success=True,
data=buffer.read(),
data=b64_bytes
)
17 changes: 14 additions & 3 deletions API/services/user_group_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from repositories.group_repository import IGroupRepository
from repositories.user_group_repository import IUserGroupRepository
from repositories.user_repository import IUserRepository
from repositories.group_log_repository import IGroupLogRepository
from schemas.api_response import APIResponse
from schemas.group import GroupResponse
from schemas.user import UserResponse
Expand Down Expand Up @@ -53,6 +54,7 @@ def __init__(
repository: IUserGroupRepository,
group_repo: IGroupRepository,
user_repo: IUserRepository,
log_repo: IGroupLogRepository,
):
"""
Constructor method.
Expand All @@ -61,6 +63,7 @@ def __init__(
self.group_repo = group_repo
self.user_repo = user_repo
self.logger = Logger()
self.log_repo = log_repo

def _validate_group(self, group_id: int = None, invitation_code: str = None) -> Group:
if group_id is not None:
Expand Down Expand Up @@ -128,9 +131,17 @@ def add_user_to_group_by_invitation_code(self, user_id: int, invitation_code: st
)

response = self.repository.add_user_to_group_by_invitation_code(user_id, invitation_code)

group_id = response[0]
user_id = response[1]

group_response = GroupResponse.model_validate(response[0])
user_response = UserResponse.model_validate(response[1])

# log the join event
if self.log_repo:
self.log_repo.add(
group_id=group.id,
user_id=user_id,
action="JOIN"
)

return APIResponse(
success=True,
Expand Down
10 changes: 5 additions & 5 deletions API/utils/helpers/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@


# Fields for group statistics
TOTAL_GROUP_SPEND = "total_group_spend"
MY_TOTAL_PAID = "my_total_paid"
MY_SHARE_OF_EXPENSES = "my_share_of_expenses"
NET_BALANCE_PAID_FOR_OTHERS = "net_balance_paid_for_others"
REST_OF_GROUP_EXPENSES = "rest_of_group_expenses"
TOTAL_GROUP_SPEND="total_group_spend"
MY_TOTAL_PAID="my_total_paid"
MY_SHARE_OF_EXPENSES="my_share_of_expenses"
NET_BALANCE_PAID_FOR_OTHERS="net_balance_paid_for_others"
REST_OF_GROUP_EXPENSES="rest_of_group_expenses"
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.example.budgeting.android.data.model

import com.squareup.moshi.Json

class BudgetResponse(
@Json(name = "budget")
val budget: Double,

@Json(name = "spent_this_month")
val spentThisMonth: Double,

@Json(name = "remaining_budget")
val remainingBudget: Double
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.example.budgeting.android.data.model

import com.squareup.moshi.Json

data class Category(
@Json(name = "id")
val id: Int,

@Json(name = "user_id")
val user_id: Int,

@Json(name = "title")
val title: String,

@Json(name = "keywords")
val keywords: List<String> = emptyList()
)

/**
* Request model for creating categories
*/
data class CategoryCreateRequest(
@Json(name = "title")
val title: String,

@Json(name = "keywords")
val keywords: List<String> = emptyList()
)

/**
* Request model for updating categories
*/
data class CategoryUpdateRequest(
@Json(name = "title")
val title: String? = null,

@Json(name = "keywords")
val keywords: List<String>? = null
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.example.budgeting.android.data.model

data class CategoryBody(
val title: String? = null,
val keywords: List<String>? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,6 @@ package com.example.budgeting.android.data.model

import com.squareup.moshi.Json

/**
* Expense model matching the backend schema.
* Backend schema: id (int, required), user_id (int?, optional), group_id (int?, optional),
* title (str, required), category (str, required), amount (float, required), created_at (datetime, required)
*
* Note: id and created_at are nullable to support creating new expenses (they're generated by backend),
* but when receiving from backend, they should always be present. However, we make them nullable
* to handle cases where the backend might return malformed data.
*/
data class Expense(
@Json(name = "id")
val id: Int? = null,
Expand All @@ -23,18 +14,38 @@ data class Expense(

@Json(name = "title")
val title: String,
@Json(name = "category")
val category: String,

@Json(name = "category_id")
val categoryId: Int? = null,

@Json(name = "amount")
val amount: Double,

@Json(name = "description")
val description: String? = null,

@Json(name = "created_at")
val created_at: String? = null
)

data class ExpenseIdResponse(
@Json(name = "id")
val id: Int
)

data class ExpenseCreateRequest(
@Json(name = "title")
val title: String,

@Json(name = "amount")
val amount: Double,

@Json(name = "category_id")
val category_id: Int,

@Json(name = "group_id")
val group_id: Int? = null,

@Json(name = "description")
val description: String? = null
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.example.budgeting.android.data.model

import com.squareup.moshi.Json

data class ExpensePayment(
@Json(name = "expense_id")
val expense_id: Int,

@Json(name = "user_id")
val user_id: Int,

@Json(name = "paid_at")
val paid_at: String? = null
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.example.budgeting.android.data.model

import com.squareup.moshi.Json

data class GroupLog(
@Json(name = "id")
val id: Int,

@Json(name = "group_id")
val group_id: Int,

@Json(name = "user_id")
val user_id: Int,

@Json(name = "action")
val action: String, // "JOIN" or "LEAVE"

@Json(name = "created_at")
val created_at: String
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.example.budgeting.android.data.model

import com.squareup.moshi.Json

data class GroupIdResponse(
@Json(name = "id")
val id: Int
)

data class AddUserToGroupResponse(
@Json(name = "group")
val group: Int,
@Json(name = "user")
val user: Int
)

data class JoinGroupResponse(
@Json(name = "group")
val group: Group,
@Json(name = "user")
val user: UserData
)

Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.example.budgeting.android.data.network

import com.example.budgeting.android.data.model.ApiResponse
import com.example.budgeting.android.data.model.Category
import com.example.budgeting.android.data.model.CategoryBody
import retrofit2.Response
import retrofit2.http.*

interface CategoryApiService {
@GET("/categories")
suspend fun getCategories(
@Query("sort_by") sortBy: String? = null,
@Query("order") order: String? = null
): Response<ApiResponse<List<Category>>>

@POST("/categories")
suspend fun addCategory(@Body category: CategoryBody): Response<Unit>

@PUT("/categories/{id}")
suspend fun updateCategory(@Path("id") id: Int, @Body category: CategoryBody): Response<Unit>

@DELETE("/categories/{id}")
suspend fun deleteCategory(@Path("id") id: Int): Response<Unit>

}
Loading
Loading