Skip to content

Commit 95c4132

Browse files
authored
Merge pull request #29 from bjcoleman/delete_group
Implement and test group deletion.
2 parents d5ea3c9 + 30d7b6a commit 95c4132

7 files changed

Lines changed: 495 additions & 1 deletion

File tree

src/cost_sharing/app.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,46 @@ def get_group(groupId): # pylint: disable=C0103
324324
"message": "Access denied"
325325
}), 403
326326

327+
@app.route('/groups/<int:groupId>', methods=['DELETE'])
328+
@require_auth
329+
def delete_group(groupId): # pylint: disable=C0103, R0911
330+
"""
331+
Delete a group (only if no expenses exist).
332+
333+
Requires valid JWT token in Authorization header.
334+
Caller must be a member of the group.
335+
Group can only be deleted if it has no expenses.
336+
Returns 204 No Content on successful deletion.
337+
"""
338+
# Get user_id from g (set by require_auth decorator)
339+
user_id = g.user_id
340+
341+
try:
342+
# Delete group from application layer (includes all authorization
343+
# checks and expense validation)
344+
application.delete_group(groupId, user_id)
345+
346+
# Return 204 No Content (no response body)
347+
return '', 204
348+
349+
except GroupNotFoundError:
350+
return jsonify({
351+
"error": "Resource not found",
352+
"message": "Group not found"
353+
}), 404
354+
355+
except ForbiddenError:
356+
return jsonify({
357+
"error": "Forbidden",
358+
"message": "Access denied"
359+
}), 403
360+
361+
except ConflictError as e:
362+
return jsonify({
363+
"error": "Conflict",
364+
"message": str(e)
365+
}), 409
366+
327367
@app.route('/groups/<int:groupId>/members', methods=['POST'])
328368
@require_auth
329369
def add_group_member(groupId): # pylint: disable=C0103, R0911, R0912

src/cost_sharing/cost_sharing.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,30 @@ def remove_group_member(self, group_id, user_id, caller_user_id): # pylint: disa
200200
# Remove member from group
201201
self._storage.delete_group_member(group_id, user_id)
202202

203+
def delete_group(self, group_id, user_id):
204+
"""
205+
Delete a group (only if no expenses exist).
206+
207+
Args:
208+
group_id: Group ID to delete
209+
user_id: User ID of the requesting user (must be a member)
210+
211+
Raises:
212+
GroupNotFoundError: If group doesn't exist
213+
ForbiddenError: If user is not a member of the group
214+
ConflictError: If group has existing expenses
215+
"""
216+
# Verify authorization (raises GroupNotFoundError or ForbiddenError if invalid)
217+
self.get_group_by_id(group_id, user_id)
218+
219+
# Check if group has any expenses
220+
expenses = self._storage.get_group_expenses(group_id)
221+
if len(expenses) > 0:
222+
raise ConflictError("Cannot delete group with existing expenses")
223+
224+
# Delete group in storage layer
225+
self._storage.delete_group(group_id)
226+
203227
def get_group_expenses(self, group_id, user_id):
204228
"""
205229
Get all expenses for a group, ensuring the user is a member.

src/cost_sharing/db_storage.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,29 @@ def delete_group_member(self, group_id, user_id):
327327
self._conn.rollback()
328328
raise StorageException(f"Database error deleting member: {e}") from e
329329

330+
def delete_group(self, group_id):
331+
"""
332+
Delete a group by ID.
333+
334+
The group_members records will be automatically deleted
335+
via CASCADE foreign key constraint.
336+
337+
Args:
338+
group_id: Group ID to delete
339+
340+
Raises:
341+
StorageException: If a database error occurs
342+
"""
343+
try:
344+
self._conn.execute(
345+
'DELETE FROM groups WHERE id = ?',
346+
(group_id,)
347+
)
348+
self._conn.commit()
349+
except sqlite3.Error as e:
350+
self._conn.rollback()
351+
raise StorageException(f"Database error deleting group: {e}") from e
352+
330353
def get_group_expenses(self, group_id):
331354
"""
332355
Get all expenses for a group.

src/cost_sharing/static/js/script.js

Lines changed: 212 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,218 @@ function submitCreateGroup(event) {
310310
}
311311

312312
function handleDeleteGroup(groupId) {
313-
alert('Not implemented');
313+
if (!currentToken) {
314+
showError('You must be logged in to delete a group');
315+
return;
316+
}
317+
if (!currentGroup || currentGroup.id !== groupId) {
318+
// Need to fetch group details first
319+
fetchGroupDetailsForDelete(groupId);
320+
} else {
321+
showDeleteGroupConfirmationModal(currentGroup);
322+
}
323+
}
324+
325+
function fetchGroupDetailsForDelete(groupId) {
326+
fetch(`${API_BASE}/groups/${groupId}`, {
327+
headers: {
328+
'Authorization': `Bearer ${currentToken}`
329+
}
330+
})
331+
.then(response => {
332+
if (!response.ok) {
333+
if (response.status === 401) {
334+
logout();
335+
throw new Error('Authentication failed');
336+
}
337+
if (response.status === 403) {
338+
throw new Error('You do not have access to this group');
339+
}
340+
if (response.status === 404) {
341+
throw new Error('Group not found');
342+
}
343+
return response.json().then(data => {
344+
throw new Error(data.message || 'Failed to fetch group details');
345+
});
346+
}
347+
return response.json();
348+
})
349+
.then(group => {
350+
showDeleteGroupConfirmationModal(group);
351+
})
352+
.catch(error => {
353+
console.error('Error:', error);
354+
showError(error.message || 'Failed to fetch group details');
355+
});
356+
}
357+
358+
function showDeleteGroupConfirmationModal(group) {
359+
// Create modal if it doesn't exist
360+
let modalOverlay = document.getElementById('delete-group-modal');
361+
if (!modalOverlay) {
362+
modalOverlay = document.createElement('div');
363+
modalOverlay.id = 'delete-group-modal';
364+
modalOverlay.className = 'modal-overlay';
365+
modalOverlay.innerHTML = `
366+
<div class="modal">
367+
<div class="modal-header">
368+
<h2>Delete Group</h2>
369+
<button class="modal-close" onclick="closeDeleteGroupModal()">&times;</button>
370+
</div>
371+
<p>Are you sure you want to delete this group?</p>
372+
<p style="font-weight: 600; color: #333; margin: 10px 0;">"<span id="delete-group-name"></span>"</p>
373+
<p style="color: #6c757d; font-size: 0.9em;">This action cannot be undone. The group can only be deleted if it has no expenses.</p>
374+
<div class="form-actions">
375+
<button type="button" class="secondary" onclick="closeDeleteGroupModal()">Cancel</button>
376+
<button type="button" class="danger" id="confirm-delete-group-btn" onclick="confirmDeleteGroup()">Delete</button>
377+
</div>
378+
</div>
379+
`;
380+
document.body.appendChild(modalOverlay);
381+
382+
// Close modal when clicking outside
383+
modalOverlay.addEventListener('click', function(event) {
384+
if (event.target === modalOverlay) {
385+
closeDeleteGroupModal();
386+
}
387+
});
388+
}
389+
390+
// Update group name in modal
391+
const nameEl = document.getElementById('delete-group-name');
392+
if (nameEl) {
393+
nameEl.textContent = group.name || '';
394+
}
395+
396+
// Store group ID for deletion
397+
modalOverlay.dataset.groupId = group.id;
398+
399+
modalOverlay.classList.add('active');
400+
}
401+
402+
function closeDeleteGroupModal() {
403+
const modalOverlay = document.getElementById('delete-group-modal');
404+
if (modalOverlay) {
405+
modalOverlay.classList.remove('active');
406+
}
407+
}
408+
409+
function showDeleteGroupErrorModal(errorMessage) {
410+
// Create modal if it doesn't exist
411+
let modalOverlay = document.getElementById('delete-group-error-modal');
412+
if (!modalOverlay) {
413+
modalOverlay = document.createElement('div');
414+
modalOverlay.id = 'delete-group-error-modal';
415+
modalOverlay.className = 'modal-overlay';
416+
modalOverlay.innerHTML = `
417+
<div class="modal">
418+
<div class="modal-header">
419+
<h2>Error</h2>
420+
<button class="modal-close" onclick="closeDeleteGroupErrorModal()">&times;</button>
421+
</div>
422+
<p style="color: #dc3545; font-weight: 600; margin: 10px 0;"><span id="delete-group-error-message"></span></p>
423+
<div class="form-actions">
424+
<button type="button" class="secondary" onclick="closeDeleteGroupErrorModal()">Close</button>
425+
</div>
426+
</div>
427+
`;
428+
document.body.appendChild(modalOverlay);
429+
430+
// Close modal when clicking outside
431+
modalOverlay.addEventListener('click', function(event) {
432+
if (event.target === modalOverlay) {
433+
closeDeleteGroupErrorModal();
434+
}
435+
});
436+
}
437+
438+
// Update error message in modal
439+
const messageEl = document.getElementById('delete-group-error-message');
440+
if (messageEl) {
441+
messageEl.textContent = errorMessage;
442+
}
443+
444+
modalOverlay.classList.add('active');
445+
}
446+
447+
function closeDeleteGroupErrorModal() {
448+
const modalOverlay = document.getElementById('delete-group-error-modal');
449+
if (modalOverlay) {
450+
modalOverlay.classList.remove('active');
451+
}
452+
}
453+
454+
function confirmDeleteGroup() {
455+
const modalOverlay = document.getElementById('delete-group-modal');
456+
if (!modalOverlay) {
457+
return;
458+
}
459+
460+
const groupId = parseInt(modalOverlay.dataset.groupId);
461+
462+
if (!groupId) {
463+
showError('Group information not available');
464+
return;
465+
}
466+
467+
if (!currentToken) {
468+
showError('You must be logged in to delete a group');
469+
closeDeleteGroupModal();
470+
return;
471+
}
472+
473+
// Disable delete button during submission
474+
const deleteButton = document.getElementById('confirm-delete-group-btn');
475+
const originalText = deleteButton.textContent;
476+
deleteButton.disabled = true;
477+
deleteButton.textContent = 'Deleting...';
478+
479+
fetch(`${API_BASE}/groups/${groupId}`, {
480+
method: 'DELETE',
481+
headers: {
482+
'Authorization': `Bearer ${currentToken}`
483+
}
484+
})
485+
.then(response => {
486+
if (!response.ok) {
487+
if (response.status === 401) {
488+
logout();
489+
throw new Error('Authentication failed');
490+
}
491+
if (response.status === 403) {
492+
throw new Error('You do not have permission to delete this group');
493+
}
494+
if (response.status === 404) {
495+
throw new Error('Group not found');
496+
}
497+
if (response.status === 409) {
498+
return response.json().then(data => {
499+
throw new Error(data.message || 'Cannot delete this group');
500+
});
501+
}
502+
return response.json().then(data => {
503+
throw new Error(data.message || 'Failed to delete group');
504+
});
505+
}
506+
// DELETE returns 204 No Content, so no JSON to parse
507+
return null;
508+
})
509+
.then(() => {
510+
closeDeleteGroupModal();
511+
// Go back to groups list
512+
showGroupsList();
513+
// Reload groups list
514+
fetchGroups();
515+
})
516+
.catch(error => {
517+
console.error('Error:', error);
518+
closeDeleteGroupModal();
519+
showDeleteGroupErrorModal(error.message || 'Failed to delete group');
520+
})
521+
.finally(() => {
522+
deleteButton.disabled = false;
523+
deleteButton.textContent = originalText;
524+
});
314525
}
315526

316527
function viewGroupDetails(groupId) {

0 commit comments

Comments
 (0)