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
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:
parent
8afeda1df7
commit
3ec2ff1f89
@ -13,6 +13,7 @@ from app.schemas.invite import InviteCodePublic
|
|||||||
from app.schemas.message import Message # For simple responses
|
from app.schemas.message import Message # For simple responses
|
||||||
from app.schemas.list import ListPublic, ListDetail
|
from app.schemas.list import ListPublic, ListDetail
|
||||||
from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentPublic
|
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 group as crud_group
|
||||||
from app.crud import invite as crud_invite
|
from app.crud import invite as crud_invite
|
||||||
from app.crud import list as crud_list
|
from app.crud import list as crud_list
|
||||||
@ -92,6 +93,33 @@ async def read_group(
|
|||||||
|
|
||||||
return 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(
|
@router.post(
|
||||||
"/{group_id}/invites",
|
"/{group_id}/invites",
|
||||||
|
@ -81,7 +81,8 @@
|
|||||||
body {
|
body {
|
||||||
font-family: 'Patrick Hand', cursive;
|
font-family: 'Patrick Hand', cursive;
|
||||||
background-color: var(--light);
|
background-color: var(--light);
|
||||||
background-image: var(--paper-texture);
|
// background-image: var(--paper-texture);
|
||||||
|
// background-image: url('@/assets/11.webp');
|
||||||
// padding: 2rem 1rem;s
|
// padding: 2rem 1rem;s
|
||||||
color: var(--dark);
|
color: var(--dark);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
|
@ -97,6 +97,8 @@
|
|||||||
"addChore": "+",
|
"addChore": "+",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"editChore": "Edit Chore",
|
||||||
|
"createChore": "Create Chore",
|
||||||
"empty": {
|
"empty": {
|
||||||
"title": "No Chores Yet",
|
"title": "No Chores Yet",
|
||||||
"message": "Get started by adding your first chore!",
|
"message": "Get started by adding your first chore!",
|
||||||
@ -170,6 +172,23 @@
|
|||||||
"loadingLabel": "Loading group details...",
|
"loadingLabel": "Loading group details...",
|
||||||
"retryButton": "Retry",
|
"retryButton": "Retry",
|
||||||
"groupNotFound": "Group not found or an error occurred.",
|
"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": {
|
"members": {
|
||||||
"title": "Group Members",
|
"title": "Group Members",
|
||||||
"defaultRole": "Member",
|
"defaultRole": "Member",
|
||||||
@ -201,12 +220,6 @@
|
|||||||
"console": {
|
"console": {
|
||||||
"noActiveInvite": "No active invite code found for this group."
|
"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": {
|
"expenses": {
|
||||||
"title": "Group Expenses",
|
"title": "Group Expenses",
|
||||||
"manageButton": "Manage Expenses",
|
"manageButton": "Manage Expenses",
|
||||||
@ -445,6 +458,8 @@
|
|||||||
"addExpenseButton": "Add Expense",
|
"addExpenseButton": "Add Expense",
|
||||||
"loading": "Loading expenses...",
|
"loading": "Loading expenses...",
|
||||||
"emptyState": "No expenses recorded for this list yet.",
|
"emptyState": "No expenses recorded for this list yet.",
|
||||||
|
"emptyStateTitle": "No Expenses",
|
||||||
|
"emptyStateMessage": "Add your first expense to get started.",
|
||||||
"paidBy": "Paid by:",
|
"paidBy": "Paid by:",
|
||||||
"onDate": "on",
|
"onDate": "on",
|
||||||
"owes": "owes",
|
"owes": "owes",
|
||||||
|
@ -94,6 +94,11 @@
|
|||||||
},
|
},
|
||||||
"choresPage": {
|
"choresPage": {
|
||||||
"title": "Taken",
|
"title": "Taken",
|
||||||
|
"addChore": "+",
|
||||||
|
"edit": "Bewerken",
|
||||||
|
"delete": "Verwijderen",
|
||||||
|
"editChore": "Taak bewerken",
|
||||||
|
"createChore": "Nieuwe taak",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"overdue": "Achterstallig",
|
"overdue": "Achterstallig",
|
||||||
"today": "Vandaag",
|
"today": "Vandaag",
|
||||||
@ -339,6 +344,16 @@
|
|||||||
"partiallyPaid": "Gedeeltelijk betaald",
|
"partiallyPaid": "Gedeeltelijk betaald",
|
||||||
"unpaid": "Onbetaald",
|
"unpaid": "Onbetaald",
|
||||||
"unknown": "Onbekende status"
|
"unknown": "Onbekende status"
|
||||||
|
},
|
||||||
|
"lists": {
|
||||||
|
"title": "Groepslijsten"
|
||||||
|
},
|
||||||
|
"generateScheduleModal": {
|
||||||
|
"title": "Schema genereren"
|
||||||
|
},
|
||||||
|
"activityLog": {
|
||||||
|
"title": "Activiteitenlogboek",
|
||||||
|
"emptyState": "Nog geen activiteiten om weer te geven."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"accountPage": {
|
"accountPage": {
|
||||||
|
@ -316,22 +316,23 @@
|
|||||||
<VAlert v-else-if="costSummaryError" type="error" :message="costSummaryError" />
|
<VAlert v-else-if="costSummaryError" type="error" :message="costSummaryError" />
|
||||||
<div v-else-if="listCostSummary">
|
<div v-else-if="listCostSummary">
|
||||||
<div class="mb-3 cost-overview">
|
<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>
|
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>
|
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>
|
listCostSummary.num_participating_users }}</p>
|
||||||
</div>
|
</div>
|
||||||
<h4>{{ $t('listDetailPage.costSummaryModal.userBalancesHeader') }}</h4>
|
<h4>{{ $t('listDetailPage.modals.costSummary.userBalancesHeader') }}</h4>
|
||||||
<div class="table-container mt-2">
|
<div class="table-container mt-2">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ $t('listDetailPage.costSummaryModal.tableHeaders.user') }}</th>
|
<th>{{ $t('listDetailPage.modals.costSummary.tableHeaders.user') }}</th>
|
||||||
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.itemsAddedValue') }}</th>
|
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.itemsAddedValue') }}
|
||||||
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.amountDue') }}</th>
|
</th>
|
||||||
<th class="text-right">{{ $t('listDetailPage.costSummaryModal.tableHeaders.balance') }}</th>
|
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.amountDue') }}</th>
|
||||||
|
<th class="text-right">{{ $t('listDetailPage.modals.costSummary.tableHeaders.balance') }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -348,7 +349,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else>{{ $t('listDetailPage.costSummaryModal.emptyState') }}</p>
|
<p v-else>{{ $t('listDetailPage.modals.costSummary.emptyState') }}</p>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<VButton variant="primary" @click="showCostSummaryDialog = false">{{ $t('listDetailPage.buttons.close') }}
|
<VButton variant="primary" @click="showCostSummaryDialog = false">{{ $t('listDetailPage.buttons.close') }}
|
||||||
@ -357,7 +358,7 @@
|
|||||||
</VModal>
|
</VModal>
|
||||||
|
|
||||||
<!-- Settle Share Modal -->
|
<!-- 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">
|
@update:modelValue="!$event && closeSettleShareModal()" size="md">
|
||||||
<template #default>
|
<template #default>
|
||||||
<div v-if="isSettlementLoading" class="text-center">
|
<div v-if="isSettlementLoading" class="text-center">
|
||||||
@ -365,11 +366,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<VAlert v-else-if="settleAmountError" type="error" :message="settleAmountError" />
|
<VAlert v-else-if="settleAmountError" type="error" :message="settleAmountError" />
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<p>{{ $t('listDetailPage.settleShareModal.settleAmountFor', {
|
<p>{{ $t('listDetailPage.modals.settleShare.settleAmountFor', {
|
||||||
userName: selectedSplitForSettlement?.user?.name
|
userName: selectedSplitForSettlement?.user?.name
|
||||||
|| selectedSplitForSettlement?.user?.email || `User ID: ${selectedSplitForSettlement?.user_id}`
|
|| selectedSplitForSettlement?.user?.email || `User ID: ${selectedSplitForSettlement?.user_id}`
|
||||||
}) }}</p>
|
}) }}</p>
|
||||||
<VFormField :label="$t('listDetailPage.settleShareModal.amountLabel')"
|
<VFormField :label="$t('listDetailPage.modals.settleShare.amountLabel')"
|
||||||
:error-message="settleAmountError || undefined">
|
:error-message="settleAmountError || undefined">
|
||||||
<VInput type="number" v-model="settleAmount" id="settleAmount" required />
|
<VInput type="number" v-model="settleAmount" id="settleAmount" required />
|
||||||
</VFormField>
|
</VFormField>
|
||||||
@ -377,9 +378,10 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<VButton variant="neutral" @click="closeSettleShareModal">{{
|
<VButton variant="neutral" @click="closeSettleShareModal">{{
|
||||||
$t('listDetailPage.settleShareModal.cancelButton')
|
$t('listDetailPage.modals.settleShare.cancelButton')
|
||||||
}}</VButton>
|
}}</VButton>
|
||||||
<VButton variant="primary" @click="handleConfirmSettle">{{ $t('listDetailPage.settleShareModal.confirmButton')
|
<VButton variant="primary" @click="handleConfirmSettle">{{
|
||||||
|
$t('listDetailPage.modals.settleShare.confirmButton')
|
||||||
}}</VButton>
|
}}</VButton>
|
||||||
</template>
|
</template>
|
||||||
</VModal>
|
</VModal>
|
||||||
@ -1215,12 +1217,12 @@ const closeSettleShareModal = () => {
|
|||||||
const validateSettleAmount = (): boolean => {
|
const validateSettleAmount = (): boolean => {
|
||||||
settleAmountError.value = null;
|
settleAmountError.value = null;
|
||||||
if (!settleAmount.value.trim()) {
|
if (!settleAmount.value.trim()) {
|
||||||
settleAmountError.value = t('listDetailPage.settleShareModal.errors.enterAmount');
|
settleAmountError.value = t('listDetailPage.modals.settleShare.errors.enterAmount');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const amount = new Decimal(settleAmount.value);
|
const amount = new Decimal(settleAmount.value);
|
||||||
if (amount.isNaN() || amount.isNegative() || amount.isZero()) {
|
if (amount.isNaN() || amount.isNegative() || amount.isZero()) {
|
||||||
settleAmountError.value = t('listDetailPage.settleShareModal.errors.positiveAmount');
|
settleAmountError.value = t('listDetailPage.modals.settleShare.errors.positiveAmount');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (selectedSplitForSettlement.value) {
|
if (selectedSplitForSettlement.value) {
|
||||||
@ -1228,11 +1230,11 @@ const validateSettleAmount = (): boolean => {
|
|||||||
const owed = new Decimal(selectedSplitForSettlement.value.owed_amount);
|
const owed = new Decimal(selectedSplitForSettlement.value.owed_amount);
|
||||||
const remaining = owed.minus(alreadyPaid);
|
const remaining = owed.minus(alreadyPaid);
|
||||||
if (amount.greaterThan(remaining.plus(new Decimal('0.001')))) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
settleAmountError.value = t('listDetailPage.settleShareModal.errors.noSplitSelected');
|
settleAmountError.value = t('listDetailPage.modals.settleShare.errors.noSplitSelected');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
@ -29,8 +29,8 @@
|
|||||||
<div class="neo-lists-grid">
|
<div class="neo-lists-grid">
|
||||||
<div v-for="list in lists" :key="list.id" class="neo-list-card"
|
<div v-for="list in lists" :key="list.id" class="neo-list-card"
|
||||||
:class="{ 'touch-active': touchActiveListId === list.id }" @click="navigateToList(list.id)"
|
:class="{ 'touch-active': touchActiveListId === list.id }" @click="navigateToList(list.id)"
|
||||||
@touchstart="handleTouchStart(list.id)" @touchend="handleTouchEnd" @touchcancel="handleTouchEnd"
|
@touchstart.passive="handleTouchStart(list.id)" @touchend.passive="handleTouchEnd"
|
||||||
:data-list-id="list.id">
|
@touchcancel.passive="handleTouchEnd" :data-list-id="list.id">
|
||||||
<div class="neo-list-header">{{ list.name }}</div>
|
<div class="neo-list-header">{{ list.name }}</div>
|
||||||
<div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div>
|
<div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div>
|
||||||
<ul class="neo-item-list">
|
<ul class="neo-item-list">
|
||||||
|
Loading…
Reference in New Issue
Block a user