feat: Implement mark and delete functionality
This commit is contained in:
73
main.py
73
main.py
@@ -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:
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user