diff --git a/be/app/api/v1/endpoints/financials.py b/be/app/api/v1/endpoints/financials.py index 2f9a9ad..0013e41 100644 --- a/be/app/api/v1/endpoints/financials.py +++ b/be/app/api/v1/endpoints/financials.py @@ -39,7 +39,7 @@ router = APIRouter() # --- Helper for permissions --- async def check_list_access_for_financials(db: AsyncSession, list_id: int, user_id: int, action: str = "access financial data for"): try: - await crud_list.check_list_permission(db=db, list_id=list_id, user_id=user_id, require_member=True) + await crud_list.check_list_permission(db=db, list_id=list_id, user_id=user_id, require_creator=False) except ListPermissionError as e: logger.warning(f"ListPermissionError in check_list_access_for_financials for list {list_id}, user {user_id}, action '{action}': {e.detail}") raise ListPermissionError(list_id, action=action) @@ -135,17 +135,41 @@ async def get_expense( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to view this expense") return expense -@router.get("/lists/{list_id}/expenses", response_model=PyList[ExpensePublic], summary="List Expenses for a List", tags=["Expenses", "Lists"]) -async def list_list_expenses( - list_id: int, +@router.get("/expenses", response_model=PyList[ExpensePublic], summary="List Expenses", tags=["Expenses"]) +async def list_expenses( + list_id: Optional[int] = Query(None, description="Filter by list ID"), + group_id: Optional[int] = Query(None, description="Filter by group ID"), + isRecurring: Optional[bool] = Query(None, description="Filter by recurring expenses"), skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200), db: AsyncSession = Depends(get_transactional_session), current_user: UserModel = Depends(current_active_user), ): - logger.info(f"User {current_user.email} listing expenses for list ID {list_id}") - await check_list_access_for_financials(db, list_id, current_user.id) - expenses = await crud_expense.get_expenses_for_list(db, list_id=list_id, skip=skip, limit=limit) + """ + List expenses with optional filters. + If list_id is provided, returns expenses for that list (user must have list access). + If group_id is provided, returns expenses for that group (user must be group member). + If both are provided, returns expenses for the list (list_id takes precedence). + If neither is provided, returns all expenses the user has access to. + """ + logger.info(f"User {current_user.email} listing expenses with filters: list_id={list_id}, group_id={group_id}, isRecurring={isRecurring}") + + if list_id: + # Use existing list expenses endpoint logic + await check_list_access_for_financials(db, list_id, current_user.id) + expenses = await crud_expense.get_expenses_for_list(db, list_id=list_id, skip=skip, limit=limit) + elif group_id: + # Use existing group expenses endpoint logic + await crud_group.check_group_membership(db, group_id=group_id, user_id=current_user.id, action="list expenses for") + expenses = await crud_expense.get_expenses_for_group(db, group_id=group_id, skip=skip, limit=limit) + else: + # Get all expenses the user has access to (user's personal expenses + group expenses + list expenses) + expenses = await crud_expense.get_user_accessible_expenses(db, user_id=current_user.id, skip=skip, limit=limit) + + # Apply recurring filter if specified + if isRecurring is not None: + expenses = [expense for expense in expenses if bool(expense.recurrence_rule) == isRecurring] + return expenses @router.get("/groups/{group_id}/expenses", response_model=PyList[ExpensePublic], summary="List Expenses for a Group", tags=["Expenses", "Groups"]) diff --git a/be/app/api/v1/endpoints/lists.py b/be/app/api/v1/endpoints/lists.py index 07e1d2f..2b28884 100644 --- a/be/app/api/v1/endpoints/lists.py +++ b/be/app/api/v1/endpoints/lists.py @@ -13,6 +13,7 @@ from app.schemas.message import Message # For simple responses from app.crud import list as crud_list from app.crud import group as crud_group # Need for group membership check from app.schemas.list import ListStatus +from app.schemas.expense import ExpensePublic # Import ExpensePublic from app.core.exceptions import ( GroupMembershipError, ListNotFoundError, @@ -215,24 +216,53 @@ async def read_list_status( current_user: UserModel = Depends(current_active_user), ): """ - Retrieves the last update time for the list and its items, plus item count. - Used for polling to check if a full refresh is needed. - Requires user to have permission to view the list. + Retrieves the completion status for a specific list + if the user has permission (creator or group member). """ - # Verify user has access to the list first + logger.info(f"User {current_user.email} requesting status for list ID: {list_id}") list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) - if not list_db: - # Check if list exists at all for correct error code - exists = await crud_list.get_list_by_id(db, list_id) - if not exists: - raise ListNotFoundError(list_id) - raise ListPermissionError(list_id, "access this list's status") + + # Calculate status + total_items = len(list_db.items) + completed_items = sum(1 for item in list_db.items if item.is_complete) + + try: + completion_percentage = (completed_items / total_items * 100) if total_items > 0 else 0 + except ZeroDivisionError: + completion_percentage = 0 + + return ListStatus( + list_id=list_db.id, + total_items=total_items, + completed_items=completed_items, + completion_percentage=completion_percentage, + last_updated=list_db.updated_at + ) - # Fetch the status details - list_status = await crud_list.get_list_status(db=db, list_id=list_id) - if not list_status: - # Should not happen if check_list_permission passed, but handle defensively - logger.error(f"Could not retrieve status for list {list_id} even though permission check passed.") - raise ListStatusNotFoundError(list_id) - - return list_status \ No newline at end of file +@router.get( + "/{list_id}/expenses", + response_model=PyList[ExpensePublic], + summary="Get Expenses for List", + tags=["Lists", "Expenses"] +) +async def read_list_expenses( + list_id: int, + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=200), + db: AsyncSession = Depends(get_transactional_session), + current_user: UserModel = Depends(current_active_user), +): + """ + Retrieves expenses associated with a specific list + if the user has permission (creator or group member). + """ + from app.crud import expense as crud_expense + + logger.info(f"User {current_user.email} requesting expenses for list ID: {list_id}") + + # Check if user has permission to access this list + await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) + + # Get expenses for this list + expenses = await crud_expense.get_expenses_for_list(db, list_id=list_id, skip=skip, limit=limit) + return expenses \ No newline at end of file diff --git a/be/app/crud/expense.py b/be/app/crud/expense.py index 5af9656..7d00901 100644 --- a/be/app/crud/expense.py +++ b/be/app/crud/expense.py @@ -203,7 +203,8 @@ async def create_expense(db: AsyncSession, expense_in: ExpenseCreate, current_us selectinload(ExpenseModel.list), selectinload(ExpenseModel.group), selectinload(ExpenseModel.item), - selectinload(ExpenseModel.splits).selectinload(ExpenseSplitModel.user) + selectinload(ExpenseModel.splits).selectinload(ExpenseSplitModel.user), + selectinload(ExpenseModel.splits).selectinload(ExpenseSplitModel.settlement_activities) ) ) result = await db.execute(stmt) @@ -535,6 +536,7 @@ async def get_expense_by_id(db: AsyncSession, expense_id: int) -> Optional[Expen select(ExpenseModel) .options( selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.user)), + selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.settlement_activities)), selectinload(ExpenseModel.paid_by_user), selectinload(ExpenseModel.list), selectinload(ExpenseModel.group), @@ -550,7 +552,10 @@ async def get_expenses_for_list(db: AsyncSession, list_id: int, skip: int = 0, l .where(ExpenseModel.list_id == list_id) .order_by(ExpenseModel.expense_date.desc(), ExpenseModel.created_at.desc()) .offset(skip).limit(limit) - .options(selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.user))) # Also load user for each split + .options( + selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.user)), + selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.settlement_activities)) + ) ) return result.scalars().all() @@ -560,7 +565,49 @@ async def get_expenses_for_group(db: AsyncSession, group_id: int, skip: int = 0, .where(ExpenseModel.group_id == group_id) .order_by(ExpenseModel.expense_date.desc(), ExpenseModel.created_at.desc()) .offset(skip).limit(limit) - .options(selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.user))) + .options( + selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.user)), + selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.settlement_activities)) + ) + ) + return result.scalars().all() + +async def get_user_accessible_expenses(db: AsyncSession, user_id: int, skip: int = 0, limit: int = 100) -> Sequence[ExpenseModel]: + """ + Get all expenses that a user has access to: + - Expenses they paid for + - Expenses in groups they are members of + - Expenses for lists they have access to + """ + from app.models import UserGroup as UserGroupModel, List as ListModel # Import here to avoid circular imports + + # Build the query for accessible expenses + # 1. Expenses paid by the user + paid_by_condition = ExpenseModel.paid_by_user_id == user_id + + # 2. Expenses in groups where user is a member + group_member_subquery = select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id) + group_expenses_condition = ExpenseModel.group_id.in_(group_member_subquery) + + # 3. Expenses for lists where user is creator or has access (simplified to creator for now) + user_lists_subquery = select(ListModel.id).where(ListModel.created_by_id == user_id) + list_expenses_condition = ExpenseModel.list_id.in_(user_lists_subquery) + + # Combine all conditions with OR + combined_condition = paid_by_condition | group_expenses_condition | list_expenses_condition + + result = await db.execute( + select(ExpenseModel) + .where(combined_condition) + .order_by(ExpenseModel.expense_date.desc(), ExpenseModel.created_at.desc()) + .offset(skip).limit(limit) + .options( + selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.user)), + selectinload(ExpenseModel.splits).options(selectinload(ExpenseSplitModel.settlement_activities)), + selectinload(ExpenseModel.paid_by_user), + selectinload(ExpenseModel.list), + selectinload(ExpenseModel.group) + ) ) return result.scalars().all() diff --git a/fe/src/config/api-config.ts b/fe/src/config/api-config.ts index cc12da9..8b5b022 100644 --- a/fe/src/config/api-config.ts +++ b/fe/src/config/api-config.ts @@ -2,7 +2,7 @@ export const API_VERSION = 'v1' // API Base URL -export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'https://mitlistbe.mohamad.dev' +export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'http://localhost:8000' // API Endpoints export const API_ENDPOINTS = { @@ -34,6 +34,7 @@ export const API_ENDPOINTS = { BY_ID: (id: string) => `/lists/${id}`, ITEMS: (listId: string) => `/lists/${listId}/items`, ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`, + EXPENSES: (listId: string) => `/lists/${listId}/expenses`, SHARE: (listId: string) => `/lists/${listId}/share`, UNSHARE: (listId: string) => `/lists/${listId}/unshare`, COMPLETE: (listId: string) => `/lists/${listId}/complete`, @@ -97,16 +98,16 @@ export const API_ENDPOINTS = { // Financials FINANCIALS: { - EXPENSES: '/api/v1/financials/expenses', - EXPENSE: (id: string) => `/api/v1/financials/expenses/${id}`, - SETTLEMENTS: '/api/v1/financials/settlements', - SETTLEMENT: (id: string) => `/api/v1/financials/settlements/${id}`, - BALANCES: '/api/v1/financials/balances', - BALANCE: (userId: string) => `/api/v1/financials/balances/${userId}`, - REPORTS: '/api/v1/financials/reports', - REPORT: (id: string) => `/api/v1/financials/reports/${id}`, - CATEGORIES: '/api/v1/financials/categories', - CATEGORY: (id: string) => `/api/v1/financials/categories/${id}`, + EXPENSES: '/financials/expenses', + EXPENSE: (id: string) => `/financials/expenses/${id}`, + SETTLEMENTS: '/financials/settlements', + SETTLEMENT: (id: string) => `/financials/settlements/${id}`, + BALANCES: '/financials/balances', + BALANCE: (userId: string) => `/financials/balances/${userId}`, + REPORTS: '/financials/reports', + REPORT: (id: string) => `/financials/reports/${id}`, + CATEGORIES: '/financials/categories', + CATEGORY: (id: string) => `/financials/categories/${id}`, }, // Health diff --git a/fe/src/pages/ListsPage.vue b/fe/src/pages/ListsPage.vue index 947c442..d382ffa 100644 --- a/fe/src/pages/ListsPage.vue +++ b/fe/src/pages/ListsPage.vue @@ -9,7 +9,7 @@ + :empty-title="t(noListsMessageKey)">