feat: Add group members endpoint and enhance UI translations
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m23s

This commit introduces a new API endpoint to retrieve group members, ensuring that only authorized users can access member information. Additionally, it updates the UI with improved translations for chore management, group lists, and activity logs in both English and Dutch. Styling adjustments in the ListDetailPage enhance user interaction, while minor changes in the SCSS file improve the overall visual presentation.
This commit is contained in:
mohamad 2025-06-08 12:29:09 +02:00
parent 8afeda1df7
commit 3ec2ff1f89
6 changed files with 88 additions and 27 deletions

View File

@ -13,6 +13,7 @@ from app.schemas.invite import InviteCodePublic
from app.schemas.message import Message # For simple responses
from app.schemas.list import ListPublic, ListDetail
from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentPublic
from app.schemas.user import UserPublic
from app.crud import group as crud_group
from app.crud import invite as crud_invite
from app.crud import list as crud_list
@ -92,6 +93,33 @@ async def read_group(
return group
@router.get(
"/{group_id}/members",
response_model=List[UserPublic],
summary="Get Group Members",
tags=["Groups"]
)
async def read_group_members(
group_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all members of a specific group, if the user is part of it."""
logger.info(f"User {current_user.email} requesting members for group ID: {group_id}")
# Check if user is a member first
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id)
if not is_member:
logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}")
raise GroupMembershipError(group_id, "view group members")
group = await crud_group.get_group_by_id(db=db, group_id=group_id)
if not group:
logger.error(f"Group {group_id} requested by member {current_user.email} not found (data inconsistency?)")
raise GroupNotFoundError(group_id)
# Extract and return just the user information from member associations
return [member_assoc.user for member_assoc in group.member_associations]
@router.post(
"/{group_id}/invites",

View File

@ -81,7 +81,8 @@
body {
font-family: 'Patrick Hand', cursive;
background-color: var(--light);
background-image: var(--paper-texture);
// background-image: var(--paper-texture);
// background-image: url('@/assets/11.webp');
// padding: 2rem 1rem;s
color: var(--dark);
font-size: 1.1rem;

View File

@ -97,6 +97,8 @@
"addChore": "+",
"edit": "Edit",
"delete": "Delete",
"editChore": "Edit Chore",
"createChore": "Create Chore",
"empty": {
"title": "No Chores Yet",
"message": "Get started by adding your first chore!",
@ -170,6 +172,23 @@
"loadingLabel": "Loading group details...",
"retryButton": "Retry",
"groupNotFound": "Group not found or an error occurred.",
"lists": {
"title": "Group Lists"
},
"generateScheduleModal": {
"title": "Generate Schedule"
},
"activityLog": {
"title": "Activity Log",
"emptyState": "No activity to show yet."
},
"chores": {
"title": "Group Chores",
"manageButton": "Manage Chores",
"duePrefix": "Due:",
"emptyState": "No chores scheduled. Click \"Manage Chores\" to create some!",
"generateScheduleButton": "Generate Schedule"
},
"members": {
"title": "Group Members",
"defaultRole": "Member",
@ -201,12 +220,6 @@
"console": {
"noActiveInvite": "No active invite code found for this group."
},
"chores": {
"title": "Group Chores",
"manageButton": "Manage Chores",
"duePrefix": "Due:",
"emptyState": "No chores scheduled. Click \"Manage Chores\" to create some!"
},
"expenses": {
"title": "Group Expenses",
"manageButton": "Manage Expenses",
@ -445,6 +458,8 @@
"addExpenseButton": "Add Expense",
"loading": "Loading expenses...",
"emptyState": "No expenses recorded for this list yet.",
"emptyStateTitle": "No Expenses",
"emptyStateMessage": "Add your first expense to get started.",
"paidBy": "Paid by:",
"onDate": "on",
"owes": "owes",

View File

@ -94,6 +94,11 @@
},
"choresPage": {
"title": "Taken",
"addChore": "+",
"edit": "Bewerken",
"delete": "Verwijderen",
"editChore": "Taak bewerken",
"createChore": "Nieuwe taak",
"tabs": {
"overdue": "Achterstallig",
"today": "Vandaag",
@ -339,6 +344,16 @@
"partiallyPaid": "Gedeeltelijk betaald",
"unpaid": "Onbetaald",
"unknown": "Onbekende status"
},
"lists": {
"title": "Groepslijsten"
},
"generateScheduleModal": {
"title": "Schema genereren"
},
"activityLog": {
"title": "Activiteitenlogboek",
"emptyState": "Nog geen activiteiten om weer te geven."
}
},
"accountPage": {

View File

@ -316,22 +316,23 @@
<VAlert v-else-if="costSummaryError" type="error" :message="costSummaryError" />
<div v-else-if="listCostSummary">
<div class="mb-3 cost-overview">
<p><strong>{{ $t('listDetailPage.costSummaryModal.totalCostLabel') }}</strong> {{
<p><strong>{{ $t('listDetailPage.modals.costSummary.totalCostLabel') }}</strong> {{
formatCurrency(listCostSummary.total_list_cost) }}</p>
<p><strong>{{ $t('listDetailPage.costSummaryModal.equalShareLabel') }}</strong> {{
<p><strong>{{ $t('listDetailPage.modals.costSummary.equalShareLabel') }}</strong> {{
formatCurrency(listCostSummary.equal_share_per_user) }}</p>
<p><strong>{{ $t('listDetailPage.costSummaryModal.participantsLabel') }}</strong> {{
<p><strong>{{ $t('listDetailPage.modals.costSummary.participantsLabel') }}</strong> {{
listCostSummary.num_participating_users }}</p>
</div>
<h4>{{ $t('listDetailPage.costSummaryModal.userBalancesHeader') }}</h4>
<h4>{{ $t('listDetailPage.modals.costSummary.userBalancesHeader') }}</h4>
<div class="table-container mt-2">
<table class="table">
<thead>
<tr>
<th>{{ $t('listDetailPage.costSummaryModal.tableHeaders.user') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.itemsAddedValue') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.amountDue') }}</th>
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.balance') }}</th>
<th>{{ $t('listDetailPage.modals.costSummary.tableHeaders.user') }}</th>
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.itemsAddedValue') }}
</th>
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.amountDue') }}</th>
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.balance') }}</th>
</tr>
</thead>
<tbody>
@ -348,7 +349,7 @@
</table>
</div>
</div>
<p v-else>{{ $t('listDetailPage.costSummaryModal.emptyState') }}</p>
<p v-else>{{ $t('listDetailPage.modals.costSummary.emptyState') }}</p>
</template>
<template #footer>
<VButton variant="primary" @click="showCostSummaryDialog = false">{{ $t('listDetailPage.buttons.close') }}
@ -357,7 +358,7 @@
</VModal>
<!-- Settle Share Modal -->
<VModal v-model="showSettleModal" :title="$t('listDetailPage.settleShareModal.title')"
<VModal v-model="showSettleModal" :title="$t('listDetailPage.modals.settleShare.title')"
@update:modelValue="!$event && closeSettleShareModal()" size="md">
<template #default>
<div v-if="isSettlementLoading" class="text-center">
@ -365,11 +366,11 @@
</div>
<VAlert v-else-if="settleAmountError" type="error" :message="settleAmountError" />
<div v-else>
<p>{{ $t('listDetailPage.settleShareModal.settleAmountFor', {
<p>{{ $t('listDetailPage.modals.settleShare.settleAmountFor', {
userName: selectedSplitForSettlement?.user?.name
|| selectedSplitForSettlement?.user?.email || `User ID: ${selectedSplitForSettlement?.user_id}`
}) }}</p>
<VFormField :label="$t('listDetailPage.settleShareModal.amountLabel')"
<VFormField :label="$t('listDetailPage.modals.settleShare.amountLabel')"
:error-message="settleAmountError || undefined">
<VInput type="number" v-model="settleAmount" id="settleAmount" required />
</VFormField>
@ -377,9 +378,10 @@
</template>
<template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{
$t('listDetailPage.settleShareModal.cancelButton')
$t('listDetailPage.modals.settleShare.cancelButton')
}}</VButton>
<VButton variant="primary" @click="handleConfirmSettle">{{ $t('listDetailPage.settleShareModal.confirmButton')
<VButton variant="primary" @click="handleConfirmSettle">{{
$t('listDetailPage.modals.settleShare.confirmButton')
}}</VButton>
</template>
</VModal>
@ -1215,12 +1217,12 @@ const closeSettleShareModal = () => {
const validateSettleAmount = (): boolean => {
settleAmountError.value = null;
if (!settleAmount.value.trim()) {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.enterAmount');
settleAmountError.value = t('listDetailPage.modals.settleShare.errors.enterAmount');
return false;
}
const amount = new Decimal(settleAmount.value);
if (amount.isNaN() || amount.isNegative() || amount.isZero()) {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.positiveAmount');
settleAmountError.value = t('listDetailPage.modals.settleShare.errors.positiveAmount');
return false;
}
if (selectedSplitForSettlement.value) {
@ -1228,11 +1230,11 @@ const validateSettleAmount = (): boolean => {
const owed = new Decimal(selectedSplitForSettlement.value.owed_amount);
const remaining = owed.minus(alreadyPaid);
if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.exceedsRemaining', { amount: formatCurrency(remaining.toFixed(2)) });
settleAmountError.value = t('listDetailPage.modals.settleShare.errors.exceedsRemaining', { amount: formatCurrency(remaining.toFixed(2)) });
return false;
}
} else {
settleAmountError.value = t('listDetailPage.settleShareModal.errors.noSplitSelected');
settleAmountError.value = t('listDetailPage.modals.settleShare.errors.noSplitSelected');
return false;
}
return true;

View File

@ -29,8 +29,8 @@
<div class="neo-lists-grid">
<div v-for="list in lists" :key="list.id" class="neo-list-card"
:class="{ 'touch-active': touchActiveListId === list.id }" @click="navigateToList(list.id)"
@touchstart="handleTouchStart(list.id)" @touchend="handleTouchEnd" @touchcancel="handleTouchEnd"
:data-list-id="list.id">
@touchstart.passive="handleTouchStart(list.id)" @touchend.passive="handleTouchEnd"
@touchcancel.passive="handleTouchEnd" :data-list-id="list.id">
<div class="neo-list-header">{{ list.name }}</div>
<div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div>
<ul class="neo-item-list">