feat: Implement mark and delete functionality

This commit is contained in:
2025-10-26 14:42:50 +01:00
parent f9a3c0f28d
commit 9e0351beb4
3 changed files with 296 additions and 21 deletions

73
main.py
View File

@@ -201,8 +201,18 @@ def init_db():
""")
if not column_exists(cursor, "items", "created_by_user_id"):
cursor.execute("ALTER TABLE items ADD COLUMN created_by_user_id INTEGER REFERENCES users(id)")
if not column_exists(cursor, "items", "marked"):
cursor.execute(
"ALTER TABLE items ADD COLUMN created_by_user_id INTEGER REFERENCES users(id)")
"ALTER TABLE items ADD COLUMN marked BOOLEAN NOT NULL DEFAULT 0")
cursor.execute('''
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
)
''')
cursor.execute("SELECT COUNT(*) FROM users")
if cursor.fetchone()[0] == 0:
@@ -220,6 +230,10 @@ def init_db():
print("Datenbank initialisiert.")
class DeletionRequest(BaseModel):
password: str
@app.get("/api/standard-items/{lang}")
async def get_standard_items_route(lang: str):
return get_standard_items(lang)
@@ -454,7 +468,7 @@ async def get_items(current_user: User = Depends(get_current_active_user)):
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
"SELECT id, name, is_standard, created_by_user_id FROM items ORDER BY name")
"SELECT id, name, is_standard, created_by_user_id, marked FROM items ORDER BY name")
items = cursor.fetchall()
conn.close()
return {"items": [dict(row) for row in items]}
@@ -491,6 +505,61 @@ async def add_item(item: Item, current_user: UserInDB = Depends(get_current_acti
return {"status": "exists"}
@app.put("/api/items/{item_id}/mark")
async def mark_item(item_id: int, current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("UPDATE items SET marked = NOT marked WHERE id = ?", (item_id,))
conn.commit()
conn.close()
return {"status": "ok"}
@app.post("/api/items/delete-marked")
async def delete_marked_items(request: DeletionRequest, current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute(
"SELECT value FROM app_settings WHERE key = 'deletion_password'")
result = cursor.fetchone()
conn.close()
if not result or not pwd_context.verify(request.password, result[0]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect password")
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("DELETE FROM items WHERE marked = 1")
conn.commit()
conn.close()
return {"status": "ok"}
@app.get("/api/settings/deletion-password")
async def get_deletion_password(current_user: User = Depends(get_current_active_user)):
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can access this setting")
return {"status": "ok"}
@app.post("/api/settings/deletion-password")
async def set_deletion_password(request: DeletionRequest, current_user: User = Depends(get_current_active_user)):
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can set the deletion password")
hashed_password = pwd_context.hash(request.password)
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
('deletion_password', hashed_password))
conn.commit()
conn.close()
return {"status": "ok"}
@app.delete("/api/items/{item_id}")
async def delete_item(item_id: int, current_user: User = Depends(get_current_active_user)):
if not current_user.is_admin:

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Geteilte Einkaufsliste</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<link rel="icon" type="image/png" href="/favicon.png">
<style>
body {
@@ -62,7 +63,7 @@
display: none; /* Hidden by default */
}
</style> </style>
<body>
<body class="pb-5">
<!-- Login View -->
<div id="login-view" class="container mt-5">
@@ -95,7 +96,15 @@
<div id="app-view" class="container mt-5">
<div class="d-flex justify-content-between align-items-center">
<h1 id="page-title" class="text-center mb-4"></h1>
<button id="logout-btn" class="btn btn-sm btn-outline-secondary">Logout</button>
<div class="d-flex align-items-center">
<button id="delete-marked-btn" class="btn btn-sm btn-outline-danger me-2" style="display: none;">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
<button id="logout-btn" class="btn btn-sm btn-outline-secondary">Logout</button>
</div>
</div>
<div id="online-users-container" class="mb-3" style="display: none;">
@@ -124,8 +133,17 @@
</div>
</div>
<div class="d-grid gap-2 mb-3">
<button id="notify-btn" class="btn btn-success"></button>
<div class="row g-2 mb-3">
<div class="col">
<div class="d-grid">
<button id="notify-btn" class="btn btn-success"></button>
</div>
</div>
<div class="col-auto">
<div class="d-grid">
<button id="select-all-btn" class="btn btn-primary"></button>
</div>
</div>
</div>
<ul id="item-list" class="list-group shadow-sm"></ul>
@@ -134,7 +152,7 @@
<!-- Admin Section -->
<div id="admin-section" class="card shadow-sm mt-5" style="display: none;">
<div id="admin-section" class="card shadow-sm mt-3" style="display: none;">
<div id="admin-panel-title" class="card-header"></div>
<div class="card-body">
@@ -188,6 +206,20 @@
<ul id="user-list" class="list-group mb-3">
<!-- Users will be populated by JavaScript -->
</ul>
<hr class="my-4">
<h5 id="set-deletion-password-title" class="card-title"></h5>
<form id="set-deletion-password-form">
<div class="row">
<div class="col-md-6 mb-3">
<label id="deletion-password-label" for="deletion-password" class="form-label"></label>
<input type="password" id="deletion-password" class="form-control" required>
</div>
</div>
<button id="set-deletion-password-button" type="submit" class="btn btn-primary"></button>
<div id="set-deletion-password-status" class="mt-2"></div>
</form>
</div>
</div>
@@ -197,6 +229,31 @@
<div id="status-bar"></div>
<!-- Deletion Password Modal -->
<div class="modal fade" id="delete-password-modal" tabindex="-1" aria-labelledby="delete-password-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="delete-password-modal-label"></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="delete-password-form">
<div class="mb-3">
<label id="delete-password-label" for="delete-password-input" class="form-label"></label>
<input type="password" class="form-control" id="delete-password-input" required>
</div>
<div id="delete-password-error" class="text-danger"></div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"></button>
<button type="button" id="confirm-delete-btn" class="btn btn-danger"></button>
</div>
</div>
</div>
</div>
<script>
// Views
const loginView = document.getElementById('login-view');
@@ -236,6 +293,17 @@
const onlineUsersContainer = document.getElementById('online-users-container');
const onlineUsersList = document.getElementById('online-users-list');
const deleteMarkedBtn = document.getElementById('delete-marked-btn');
const deletePasswordModal = new bootstrap.Modal(document.getElementById('delete-password-modal'));
const deletePasswordForm = document.getElementById('delete-password-form');
const deletePasswordInput = document.getElementById('delete-password-input');
const deletePasswordError = document.getElementById('delete-password-error');
const confirmDeleteBtn = document.getElementById('confirm-delete-btn');
const setDeletionPasswordForm = document.getElementById('set-deletion-password-form');
const deletionPasswordInput = document.getElementById('deletion-password');
const setDeletionPasswordStatus = document.getElementById('set-deletion-password-status');
const selectAllBtn = document.getElementById('select-all-btn');
let standardItems = [];
let translations = {};
let currentShoppingList = [];
@@ -437,12 +505,26 @@
document.getElementById('confirm-password-label').textContent = translations.confirm_password_label;
document.getElementById('change-password-button').textContent = translations.change_password_button;
document.getElementById('manage-users-title').textContent = translations.manage_users_title;
document.getElementById('set-deletion-password-title').textContent = translations.set_deletion_password_title;
document.getElementById('deletion-password-label').textContent = translations.deletion_password_label;
document.getElementById('set-deletion-password-button').textContent = translations.set_deletion_password_button;
}
// Deletion Modal
if (document.getElementById('delete-password-modal-label')) {
document.getElementById('delete-password-modal-label').textContent = translations.delete_password_modal_title;
document.getElementById('delete-password-label').textContent = translations.delete_password_label;
document.querySelector('#delete-password-modal .btn-secondary').textContent = translations.cancel_button;
document.getElementById('confirm-delete-btn').textContent = translations.delete_button;
}
// Common elements
if (document.getElementById('logout-btn')) {
document.getElementById('logout-btn').textContent = translations.logout_button;
}
if (document.getElementById('select-all-btn')) {
document.getElementById('select-all-btn').textContent = translations.select_all_button;
}
}
async function fetchStandardItems() {
@@ -457,24 +539,52 @@
const data = await response.json();
currentShoppingList = data.items;
itemList.innerHTML = '';
let markedItemsCount = 0;
data.items.forEach(item => {
const li = document.createElement('li');
li.className = 'list-group-item';
li.textContent = item.name;
const itemName = document.createElement('span');
itemName.textContent = item.name;
li.appendChild(itemName);
const switchContainer = document.createElement('div');
switchContainer.className = 'form-check form-switch';
if (currentUser.is_admin) {
const deleteBtn = document.createElement('span');
deleteBtn.textContent = 'X';
deleteBtn.className = 'delete-btn';
deleteBtn.onclick = () => deleteItem(item.id);
li.appendChild(deleteBtn);
const switchInput = document.createElement('input');
switchInput.className = 'form-check-input';
switchInput.type = 'checkbox';
switchInput.checked = item.marked;
switchInput.addEventListener('change', () => toggleMarkItem(item.id));
switchContainer.appendChild(switchInput);
li.appendChild(switchContainer);
if (item.marked) {
markedItemsCount++;
}
itemList.appendChild(li);
});
if (markedItemsCount > 0) {
deleteMarkedBtn.style.display = 'block';
} else {
deleteMarkedBtn.style.display = 'none';
}
handleInputChange();
}
async function toggleMarkItem(id) {
const response = await fetch(`/api/items/${id}/mark`, {
method: 'PUT',
headers: getAuthHeaders()
});
if (response.status === 401) return handleLogout();
await fetchItems();
}
async function addItemsFromInput(inputString, split) {
if (!inputString.trim()) return;
@@ -640,6 +750,72 @@
}
}
deleteMarkedBtn.addEventListener('click', () => {
deletePasswordModal.show();
});
confirmDeleteBtn.addEventListener('click', async () => {
const password = deletePasswordInput.value;
if (!password) {
deletePasswordError.textContent = translations.password_required;
return;
}
const response = await fetch('/api/items/delete-marked', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ password: password })
});
if (response.status === 401) {
deletePasswordError.textContent = translations.incorrect_password;
} else if (response.ok) {
deletePasswordModal.hide();
await fetchItems();
} else {
deletePasswordError.textContent = translations.generic_error;
}
});
async function getDeletionPassword() {
if (!currentUser.is_admin) return;
const response = await fetch('/api/settings/deletion-password', { headers: getAuthHeaders() });
if (response.status === 401) return handleLogout();
}
async function setDeletionPassword(event) {
event.preventDefault();
const password = deletionPasswordInput.value;
const response = await fetch('/api/settings/deletion-password', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ password: password })
});
if (response.status === 401) return handleLogout();
if (response.ok) {
setDeletionPasswordStatus.textContent = translations.password_set_success;
setDeletionPasswordStatus.className = 'mt-2 text-success';
} else {
const data = await response.json();
setDeletionPasswordStatus.textContent = `Error: ${data.detail}`;
setDeletionPasswordStatus.className = 'mt-2 text-danger';
}
}
async function selectAllItems() {
const allMarked = currentShoppingList.every(item => item.marked);
for (const item of currentShoppingList) {
if (allMarked || !item.marked) {
await toggleMarkItem(item.id);
}
}
}
// --- Event Listeners ---
loginForm.addEventListener('submit', handleLogin);
logoutBtn.addEventListener('click', handleLogout);
@@ -661,16 +837,23 @@
notifyBtn.addEventListener('click', sendNotification);
selectAllBtn.addEventListener('click', selectAllItems);
setDeletionPasswordForm.addEventListener('submit', setDeletionPassword);
// --- Initialisierung ---
async function initApp() {
// NOTE: Translations are now fetched before this function is called.
await fetchCurrentUser();
const promises = [
fetchCurrentUser(),
fetchStandardItems(),
fetchItems(),
fetchAndRenderUsers(),
fetchDbStatus()
fetchDbStatus(),
getDeletionPassword()
];
if (currentUser.is_admin) {
promises.push(fetchAndRenderUsers());
}
const results = await Promise.allSettled(promises);
results.forEach((result, i) => {
if (result.status === 'rejected') {
@@ -727,10 +910,11 @@
}
async function fetchAndRenderUsers() {
if (!userList) return;
if (!userList || !currentUser.is_admin) return;
const response = await fetch('/api/users', { headers: getAuthHeaders() });
if (response.status === 401) return handleLogout();
if (response.status === 403) return; // Non-admin users will get a 403 error
const users = await response.json();
userList.innerHTML = '';

View File

@@ -67,7 +67,18 @@ def get_all_translations(lang: str) -> Dict[str, str]:
"login_button": "Anmelden",
"user_created_success": "Benutzer '{username}' erfolgreich erstellt.",
"login_error_incorrect": "Benutzername oder Passwort inkorrekt.",
"login_error_generic": "Ein Fehler ist aufgetreten. Bitte erneut versuchen."
"login_error_generic": "Ein Fehler ist aufgetreten. Bitte erneut versuchen.",
"set_deletion_password_title": "Löschpasswort festlegen",
"deletion_password_label": "Löschpasswort",
"set_deletion_password_button": "Passwort festlegen",
"password_set_success": "Passwort erfolgreich festgelegt.",
"delete_password_modal_title": "Löschen bestätigen",
"delete_password_label": "Passwort zur Bestätigung eingeben",
"cancel_button": "Abbrechen",
"password_required": "Passwort erforderlich.",
"incorrect_password": "Falsches Passwort.",
"generic_error": "Ein Fehler ist aufgetreten.",
"select_all_button": "Alle auswählen"
}
else: # Fallback to English
return {
@@ -106,5 +117,16 @@ def get_all_translations(lang: str) -> Dict[str, str]:
"login_button": "Login",
"user_created_success": "User '{username}' created successfully.",
"login_error_incorrect": "Incorrect username or password.",
"login_error_generic": "An error occurred. Please try again."
"login_error_generic": "An error occurred. Please try again.",
"set_deletion_password_title": "Set Deletion Password",
"deletion_password_label": "Deletion Password",
"set_deletion_password_button": "Set Password",
"password_set_success": "Password set successfully.",
"delete_password_modal_title": "Confirm Deletion",
"delete_password_label": "Enter password to confirm",
"cancel_button": "Cancel",
"password_required": "Password required.",
"incorrect_password": "Incorrect password.",
"generic_error": "An error occurred.",
"select_all_button": "Select All"
}