Enhance financials API and list expense retrieval
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m23s

- Updated the `check_list_access_for_financials` function to allow access for list creators and members.
- Refactored the `list_expenses` endpoint to support filtering by `list_id`, `group_id`, and `isRecurring`, providing more flexible expense retrieval options.
- Introduced a new `read_list_expenses` endpoint to fetch expenses associated with a specific list, ensuring proper permission checks.
- Enhanced expense retrieval logic in the `get_expenses_for_list` and `get_user_accessible_expenses` functions to include settlement activities.
- Updated frontend API configuration to reflect new endpoint paths and ensure consistency across the application.
This commit is contained in:
Mohamad 2025-06-04 17:50:19 +02:00
parent 6306e70df7
commit 5c882996a9
6 changed files with 215 additions and 87 deletions

View File

@ -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"])

View File

@ -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
@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

View File

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

View File

@ -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

View File

@ -9,7 +9,7 @@
</VAlert>
<VCard v-else-if="lists.length === 0 && !loading" variant="empty-state" empty-icon="clipboard"
:empty-title="t(noListsMessageKey.value)">
:empty-title="t(noListsMessageKey)">
<template #default>
<p v-if="!currentGroupId">{{ t('listsPage.emptyState.personalGlobalInfo') }}</p>
<p v-else>{{ t('listsPage.emptyState.groupSpecificInfo') }}</p>
@ -74,7 +74,6 @@ import { useStorage } from '@vueuse/core';
import VAlert from '@/components/valerie/VAlert.vue'; // Adjust path as needed
import VCard from '@/components/valerie/VCard.vue'; // Adjust path as needed
import VButton from '@/components/valerie/VButton.vue'; // Adjust path as needed
import { animate } from 'motion';
const { t } = useI18n();
@ -194,33 +193,38 @@ const loadCachedData = () => {
};
const fetchLists = async () => {
loading.value = true;
error.value = null;
try {
const endpoint = currentGroupId.value
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
: API_ENDPOINTS.LISTS.BASE;
const response = await apiClient.get(endpoint);
lists.value = response.data as (List & { items: Item[] })[];
cachedLists.value = JSON.parse(JSON.stringify(response.data));
lists.value = (response.data as (List & { items: Item[] })[]).map(list => ({
...list,
items: list.items || []
}));
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : t('listsPage.errors.fetchFailed');
console.error(error.value, err);
if (cachedLists.value.length === 0) lists.value = [];
} finally {
loading.value = false;
}
};
const fetchListsAndGroups = async () => {
loading.value = true;
await Promise.all([
fetchLists(),
fetchAllAccessibleGroups()
]);
await fetchCurrentViewGroupName();
loading.value = false;
try {
await Promise.all([
fetchLists(),
fetchAllAccessibleGroups()
]);
await fetchCurrentViewGroupName();
} catch (err) {
console.error('Error in fetchListsAndGroups:', err);
} finally {
loading.value = false;
}
};
const availableGroupsForModal = computed(() => {
@ -236,7 +240,10 @@ const getGroupName = (groupId?: number | null): string | undefined => {
}
const onListCreated = (newList: List & { items: Item[] }) => {
lists.value.push(newList);
lists.value.push({
...newList,
items: newList.items || []
});
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now();
// Consider animating new list card in if desired
@ -335,7 +342,11 @@ const addNewItem = async (list: List, event: Event) => {
list.items = list.items.filter(i => i.tempId !== localTempId);
inputElement.value = originalInputValue;
inputElement.disabled = false;
animate(inputElement, { borderColor: ['red', '#ccc'] }, { duration: 0.5 });
inputElement.style.transition = 'border-color 0.5s ease';
inputElement.style.borderColor = 'red';
setTimeout(() => {
inputElement.style.borderColor = '#ccc';
}, 500);
}
};
@ -887,4 +898,4 @@ onUnmounted(() => {
.item-appear {
animation: item-appear 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
</style>
</style>

View File

@ -1,6 +1,11 @@
import { defineStore } from 'pinia'
import { apiClient, API_ENDPOINTS } from '@/services/api'
import type { Expense, ExpenseSplit, SettlementActivity, SettlementActivityPublic } from '@/types/expense'
import { apiClient, API_ENDPOINTS } from '@/config/api'
import type {
Expense,
ExpenseSplit,
SettlementActivity,
SettlementActivityPublic,
} from '@/types/expense'
import type { SettlementActivityCreate } from '@/types/expense'
import type { List } from '@/types/list'
import type { AxiosResponse } from 'axios'
@ -30,9 +35,21 @@ export const useListDetailStore = defineStore('listDetail', {
this.isLoading = true
this.error = null
try {
const endpoint = API_ENDPOINTS.LISTS.BY_ID(listId)
const response = await apiClient.get(endpoint)
this.currentList = response.data as ListWithExpenses
// Get list details
const listEndpoint = API_ENDPOINTS.LISTS.BY_ID(listId)
const listResponse = await apiClient.get(listEndpoint)
const listData = listResponse.data as List
// Get expenses for this list
const expensesEndpoint = API_ENDPOINTS.LISTS.EXPENSES(listId)
const expensesResponse = await apiClient.get(expensesEndpoint)
const expensesData = expensesResponse.data as Expense[]
// Combine into ListWithExpenses
this.currentList = {
...listData,
expenses: expensesData,
} as ListWithExpenses
} catch (err: any) {
this.error = err.response?.data?.detail || err.message || 'Failed to fetch list details'
this.currentList = null
@ -50,11 +67,9 @@ export const useListDetailStore = defineStore('listDetail', {
this.isSettlingSplit = true
this.error = null
try {
// Call the actual API endpoint
const response = await apiClient.settleExpenseSplit(
payload.expense_split_id,
payload.activity_data,
)
// Call the actual API endpoint using generic post method
const endpoint = `/financials/expense_splits/${payload.expense_split_id}/settle`
const response = await apiClient.post(endpoint, payload.activity_data)
console.log('Settlement activity created:', response.data)
// Refresh list data to show updated statuses
@ -96,31 +111,31 @@ export const useListDetailStore = defineStore('listDetail', {
},
getPaidAmountForSplit:
(state: ListDetailState) =>
(splitId: number): number => {
let totalPaid = 0
if (state.currentList && state.currentList.expenses) {
for (const expense of state.currentList.expenses) {
const split = expense.splits.find((s) => s.id === splitId)
if (split && split.settlement_activities) {
totalPaid = split.settlement_activities.reduce((sum, activity) => {
return sum + parseFloat(activity.amount_paid)
}, 0)
break
}
}
}
return totalPaid
},
getExpenseSplitById:
(state: ListDetailState) =>
(splitId: number): ExpenseSplit | undefined => {
if (!state.currentList || !state.currentList.expenses) return undefined
(splitId: number): number => {
let totalPaid = 0
if (state.currentList && state.currentList.expenses) {
for (const expense of state.currentList.expenses) {
const split = expense.splits.find((s) => s.id === splitId)
if (split) return split
if (split && split.settlement_activities) {
totalPaid = split.settlement_activities.reduce((sum, activity) => {
return sum + parseFloat(activity.amount_paid)
}, 0)
break
}
}
return undefined
},
}
return totalPaid
},
getExpenseSplitById:
(state: ListDetailState) =>
(splitId: number): ExpenseSplit | undefined => {
if (!state.currentList || !state.currentList.expenses) return undefined
for (const expense of state.currentList.expenses) {
const split = expense.splits.find((s) => s.id === splitId)
if (split) return split
}
return undefined
},
},
})