diff --git a/be/app/api/v1/endpoints/lists.py b/be/app/api/v1/endpoints/lists.py index 2b28884..e26bd14 100644 --- a/be/app/api/v1/endpoints/lists.py +++ b/be/app/api/v1/endpoints/lists.py @@ -12,7 +12,7 @@ from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail 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.list import ListStatus, ListStatusWithId from app.schemas.expense import ExpensePublic # Import ExpensePublic from app.core.exceptions import ( GroupMembershipError, @@ -106,6 +106,39 @@ async def read_lists( return lists +@router.get( + "/statuses", + response_model=PyList[ListStatusWithId], + summary="Get Status for Multiple Lists", + tags=["Lists"] +) +async def read_lists_statuses( + ids: PyList[int] = Query(...), + db: AsyncSession = Depends(get_transactional_session), + current_user: UserModel = Depends(current_active_user), +): + """ + Retrieves the status for a list of lists. + - `updated_at`: The timestamp of the last update to the list itself. + - `item_count`: The total number of items in the list. + The user must have permission to view each list requested. + Lists that the user does not have permission for will be omitted from the response. + """ + logger.info(f"User {current_user.email} requesting statuses for list IDs: {ids}") + + statuses = await crud_list.get_lists_statuses_by_ids(db=db, list_ids=ids, user_id=current_user.id) + + # The CRUD function returns a list of Row objects, so we map them to the Pydantic model + return [ + ListStatusWithId( + id=s.id, + updated_at=s.updated_at, + item_count=s.item_count, + latest_item_updated_at=s.latest_item_updated_at + ) for s in statuses + ] + + @router.get( "/{list_id}", response_model=ListDetail, # Return detailed list info including items @@ -216,28 +249,13 @@ async def read_list_status( current_user: UserModel = Depends(current_active_user), ): """ - Retrieves the completion status for a specific list + Retrieves the update timestamp and item count for a specific list if the user has permission (creator or group member). """ 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) - - # 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 - ) + # The check_list_permission is not needed here as get_list_status handles not found + await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) + return await crud_list.get_list_status(db=db, list_id=list_id) @router.get( "/{list_id}/expenses", diff --git a/be/app/crud/list.py b/be/app/crud/list.py index 644a0e0..0aa1dbb 100644 --- a/be/app/crud/list.py +++ b/be/app/crud/list.py @@ -219,27 +219,27 @@ async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, re async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus: """Gets the update timestamps and item count for a list.""" try: - list_query = select(ListModel.updated_at).where(ListModel.id == list_id) - list_result = await db.execute(list_query) - list_updated_at = list_result.scalar_one_or_none() + query = ( + select( + ListModel.updated_at, + sql_func.count(ItemModel.id).label("item_count"), + sql_func.max(ItemModel.updated_at).label("latest_item_updated_at") + ) + .select_from(ListModel) + .outerjoin(ItemModel, ItemModel.list_id == ListModel.id) + .where(ListModel.id == list_id) + .group_by(ListModel.id) + ) + result = await db.execute(query) + status = result.first() - if list_updated_at is None: + if status is None: raise ListNotFoundError(list_id) - item_status_query = ( - select( - sql_func.max(ItemModel.updated_at).label("latest_item_updated_at"), - sql_func.count(ItemModel.id).label("item_count") - ) - .where(ItemModel.list_id == list_id) - ) - item_result = await db.execute(item_status_query) - item_status = item_result.first() - return ListStatus( - list_updated_at=list_updated_at, - latest_item_updated_at=item_status.latest_item_updated_at if item_status else None, - item_count=item_status.item_count if item_status else 0 + updated_at=status.updated_at, + item_count=status.item_count, + latest_item_updated_at=status.latest_item_updated_at ) except OperationalError as e: raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}") @@ -295,4 +295,58 @@ async def get_list_by_name_and_group( except OperationalError as e: raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}") except SQLAlchemyError as e: - raise DatabaseQueryError(f"Failed to query list by name and group: {str(e)}") \ No newline at end of file + raise DatabaseQueryError(f"Failed to query list by name and group: {str(e)}") + +async def get_lists_statuses_by_ids(db: AsyncSession, list_ids: PyList[int], user_id: int) -> PyList[ListModel]: + """ + Gets status for a list of lists if the user has permission. + Status includes list updated_at and a count of its items. + """ + if not list_ids: + return [] + + try: + # First, get the groups the user is a member of + group_ids_result = await db.execute( + select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id) + ) + user_group_ids = group_ids_result.scalars().all() + + # Build the permission logic + permission_filter = or_( + # User is the creator of the list + and_(ListModel.created_by_id == user_id, ListModel.group_id.is_(None)), + # List belongs to a group the user is a member of + ListModel.group_id.in_(user_group_ids) + ) + + # Main query to get list data and item counts + query = ( + select( + ListModel.id, + ListModel.updated_at, + sql_func.count(ItemModel.id).label("item_count"), + sql_func.max(ItemModel.updated_at).label("latest_item_updated_at") + ) + .outerjoin(ItemModel, ListModel.id == ItemModel.list_id) + .where( + and_( + ListModel.id.in_(list_ids), + permission_filter + ) + ) + .group_by(ListModel.id) + ) + + result = await db.execute(query) + + # The result will be rows of (id, updated_at, item_count). + # We need to verify that all requested list_ids that the user *should* have access to are present. + # The filter in the query already handles permissions. + + return result.all() # Returns a list of Row objects with id, updated_at, item_count + + except OperationalError as e: + raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}") + except SQLAlchemyError as e: + raise DatabaseQueryError(f"Failed to get lists statuses: {str(e)}") \ No newline at end of file diff --git a/be/app/schemas/list.py b/be/app/schemas/list.py index a2d4314..b21e506 100644 --- a/be/app/schemas/list.py +++ b/be/app/schemas/list.py @@ -42,6 +42,9 @@ class ListDetail(ListBase): items: List[ItemPublic] = [] # Include list of items class ListStatus(BaseModel): - list_updated_at: datetime - latest_item_updated_at: Optional[datetime] = None # Can be null if list has no items - item_count: int \ No newline at end of file + updated_at: datetime + item_count: int + latest_item_updated_at: Optional[datetime] = None + +class ListStatusWithId(ListStatus): + id: int \ No newline at end of file diff --git a/fe/package-lock.json b/fe/package-lock.json index 91662ef..ff478d6 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -17,6 +17,7 @@ "date-fns": "^4.1.0", "motion": "^12.15.0", "pinia": "^3.0.2", + "qs": "^6.14.0", "vue": "^3.5.13", "vue-i18n": "^9.9.1", "vue-router": "^4.5.1", @@ -5684,7 +5685,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -9539,7 +9539,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10320,7 +10319,6 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -11035,7 +11033,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -11055,7 +11052,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -11072,7 +11068,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -11091,7 +11086,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", diff --git a/fe/package.json b/fe/package.json index 67af8ef..b9253fb 100644 --- a/fe/package.json +++ b/fe/package.json @@ -28,6 +28,7 @@ "date-fns": "^4.1.0", "motion": "^12.15.0", "pinia": "^3.0.2", + "qs": "^6.14.0", "vue": "^3.5.13", "vue-i18n": "^9.9.1", "vue-router": "^4.5.1", diff --git a/fe/src/pages/GroupDetailPage.vue b/fe/src/pages/GroupDetailPage.vue index ed35902..231454d 100644 --- a/fe/src/pages/GroupDetailPage.vue +++ b/fe/src/pages/GroupDetailPage.vue @@ -81,10 +81,7 @@