feat: Add 'Use Trash' functionality and fix various UI bugs

- Add a 'use_trash' column to the shopping_lists table to control whether a list uses the trash functionality.
- Add a switch in the list settings to toggle the 'use_trash' flag for each list.
- Add a switch in the admin panel to enable/disable the 'use_trash' flag for all lists at once.
- Modify the delete functionality to respect the 'use_trash' flag.
- Fix a bug where the search placeholder was not translated.
- Fix a bug where the 'Restore to' text was not translated and was displayed in the dark theme with the wrong color.
- Fix a bug where the title was always 'Noteshop' instead of 'Geteilte Einkaufsliste'.
- Fix a bug where the 'send notification without username' switch was missing its label.
This commit is contained in:
2025-12-21 21:58:12 +01:00
parent 9671737d56
commit 538a7a0d7b
5 changed files with 202 additions and 130 deletions

76
main.py
View File

@@ -1,4 +1,3 @@
from collections import defaultdict
from passlib.context import CryptContext
import os
@@ -72,10 +71,12 @@ class ItemRestore(BaseModel):
class ShoppingList(BaseModel):
id: int
name: str
use_trash: bool
class ShoppingListCreate(BaseModel):
name: str
use_trash: Optional[bool] = None
class NewUser(BaseModel):
@@ -137,7 +138,6 @@ def get_user(username: str):
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
@@ -225,9 +225,13 @@ def init_db():
cursor.execute("""
CREATE TABLE IF NOT EXISTS shopping_lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
name TEXT NOT NULL UNIQUE,
use_trash BOOLEAN NOT NULL DEFAULT 1
)
""")
if not column_exists(cursor, "shopping_lists", "use_trash"):
cursor.execute("ALTER TABLE shopping_lists ADD COLUMN use_trash BOOLEAN NOT NULL DEFAULT 1")
cursor.execute("""
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -290,8 +294,13 @@ def init_db():
print("Please change this password in a production environment.")
# Ensure default list exists even on fresh install
cursor.execute("INSERT OR IGNORE INTO shopping_lists (name) VALUES ('Standard')")
cursor.execute("INSERT OR IGNORE INTO shopping_lists (name) VALUES ('Papierkorb')")
cursor.execute("SELECT COUNT(*) FROM shopping_lists WHERE name = 'Standard'")
if cursor.fetchone()[0] == 0:
cursor.execute("INSERT INTO shopping_lists (name) VALUES ('Standard')")
cursor.execute("SELECT COUNT(*) FROM shopping_lists WHERE name = 'Papierkorb'")
if cursor.fetchone()[0] == 0:
cursor.execute("INSERT INTO shopping_lists (name) VALUES ('Papierkorb')")
conn.commit()
conn.close()
@@ -318,7 +327,7 @@ async def get_lists(current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT id, name FROM shopping_lists ORDER BY name")
cursor.execute("SELECT id, name, use_trash FROM shopping_lists ORDER BY name")
lists = cursor.fetchall()
conn.close()
return [dict(row) for row in lists]
@@ -358,7 +367,7 @@ async def create_list(list_data: ShoppingListCreate, current_user: User = Depend
conn.close()
await manager.broadcast_update()
return {"id": new_list_id, "name": name_to_insert}
return {"id": new_list_id, "name": name_to_insert, "use_trash": True}
@app.put("/api/lists/{list_id}", response_model=ShoppingList)
@@ -366,14 +375,14 @@ async def rename_list(list_id: int, list_data: ShoppingListCreate, current_user:
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("SELECT name FROM shopping_lists WHERE id = ?", (list_id,))
list_name_result = cursor.fetchone()
if list_name_result and list_name_result[0] == "Papierkorb":
cursor.execute("SELECT name, use_trash FROM shopping_lists WHERE id = ?", (list_id,))
list_result = cursor.fetchone()
if list_result and list_result[0] == "Papierkorb":
conn.close()
raise HTTPException(status_code=400, detail="Cannot rename the trash bin.")
try:
cursor.execute("UPDATE shopping_lists SET name = ? WHERE id = ?", (list_data.name, list_id))
cursor.execute("UPDATE shopping_lists SET name = ?, use_trash = ? WHERE id = ?", (list_data.name, list_data.use_trash, list_id))
conn.commit()
except sqlite3.IntegrityError:
conn.close()
@@ -382,7 +391,7 @@ async def rename_list(list_id: int, list_data: ShoppingListCreate, current_user:
conn.close()
await manager.broadcast_update()
return {"id": list_id, "name": list_data.name}
return {"id": list_id, "name": list_data.name, "use_trash": list_data.use_trash}
@app.delete("/api/lists/{list_id}", status_code=status.HTTP_200_OK)
@@ -730,9 +739,16 @@ async def delete_marked_items(request: DeletionRequest, current_user: User = Dep
# Permanent deletion from trash
cursor.execute("DELETE FROM items WHERE marked = 1 AND list_id = ?", (trash_list_id,))
else:
# Move to trash and unmark
cursor.execute("UPDATE items SET list_id = ?, marked = 0 WHERE marked = 1 AND list_id = ?", (trash_list_id, request.list_id))
# Check if the list uses the trash
cursor.execute("SELECT use_trash FROM shopping_lists WHERE id = ?", (request.list_id,))
use_trash_result = cursor.fetchone()
if use_trash_result and use_trash_result[0]:
# Move to trash and unmark
cursor.execute("UPDATE items SET list_id = ?, marked = 0 WHERE marked = 1 AND list_id = ?", (trash_list_id, request.list_id))
else:
# Delete directly
cursor.execute("DELETE FROM items WHERE marked = 1 AND list_id = ?", (request.list_id,))
conn.commit()
conn.close()
await manager.broadcast_update()
@@ -838,10 +854,6 @@ async def restore_item(item_id: int, request: ItemRestore, current_user: User =
@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:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can delete items")
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
@@ -861,8 +873,12 @@ async def delete_item(item_id: int, current_user: User = Depends(get_current_act
raise HTTPException(status_code=500, detail="Trash bin list not found.")
trash_list_id = trash_list_id_result[0]
if item_list_id == trash_list_id:
# Permanent deletion from trash
# Check if the list uses the trash
cursor.execute("SELECT use_trash FROM shopping_lists WHERE id = ?", (item_list_id,))
use_trash_result = cursor.fetchone()
if item_list_id == trash_list_id or (use_trash_result and not use_trash_result[0]):
# Permanent deletion from trash or if trash is disabled
cursor.execute("DELETE FROM items WHERE id = ?", (item_id,))
else:
# Move to trash and unmark
@@ -950,6 +966,24 @@ async def websocket_endpoint(websocket: WebSocket, token: str):
await manager.broadcast_user_list()
class UseTrashSetting(BaseModel):
enabled: bool
@app.post("/api/settings/all-lists-use-trash")
async def set_all_lists_use_trash(request: UseTrashSetting, 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 change this setting")
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
value_to_store = 1 if request.enabled else 0
cursor.execute("UPDATE shopping_lists SET use_trash = ?", (value_to_store,))
conn.commit()
conn.close()
await manager.broadcast_update()
return {"status": "ok"}
# --- Statische Dateien (Frontend) ---
# Create data directory if it doesn't exist
os.makedirs(os.path.dirname(DB_FILE), exist_ok=True)

View File

@@ -135,7 +135,7 @@
}
[data-theme="dark"] #trash-search-input,
[data-theme="dark"] #restore-to-text {
[data-theme="dark"] .restore-to-text {
color: var(--text-color);
}
@@ -319,7 +319,7 @@
</div>
</div>
<div id="restore-to-container" class="col-auto align-items-center" style="display: none;">
<span id="restore-to-text" class="text-muted"></span>
<span id="restore-to-text" class="restore-to-text"></span>
</div>
<div class="col">
<div class="d-grid">
@@ -409,6 +409,14 @@
<hr class="my-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="use-trash-all-switch">
<label class="form-check-label" for="use-trash-all-switch" id="use-trash-all-label"></label>
</div>
<div id="use-trash-all-status" class="mt-2"></div>
<hr class="my-4">
<h5 id="set-deletion-password-title" class="card-title"></h5>
<div id="password-set-message" style="display: none;">
<p>Passwort ist gesetzt.</p>
@@ -460,6 +468,10 @@
<label for="new-list-name-input" class="form-label">Neuer Name</label>
<input type="text" class="form-control" id="new-list-name-input" required>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="use-trash-switch">
<label class="form-check-label" for="use-trash-switch" id="use-trash-label"></label>
</div>
<div id="rename-list-error" class="text-danger"></div>
</form>
</div>
@@ -585,6 +597,7 @@
const newListNameInput = document.getElementById('new-list-name-input');
const renameListError = document.getElementById('rename-list-error');
const confirmRenameBtn = document.getElementById('confirm-rename-btn');
const useTrashSwitch = document.getElementById('use-trash-switch');
const deleteListModal = new bootstrap.Modal(document.getElementById('delete-list-modal'));
const confirmDeleteListBtn = document.getElementById('confirm-delete-list-btn');
@@ -601,6 +614,8 @@
const setDeletionPasswordStatus = document.getElementById('set-deletion-password-status');
const selectAllBtn = document.getElementById('select-all-btn');
const useTrashAllSwitch = document.getElementById('use-trash-all-switch');
let allLists = [];
let currentListId = null;
let currentListName = '';
@@ -788,7 +803,7 @@
// User View
pageTitle.textContent = translations.title;
itemNameInput.placeholder = translations.placeholder;
trashSearchInput.placeholder = translations.trash_search_placeholder || 'Search in trash...';
trashSearchInput.placeholder = translations.trash_search_placeholder || "Search in trash...";
addButton.textContent = translations.add_button;
addOneButton.textContent = translations.add_one_button;
notifyBtn.textContent = translations.notify_button;
@@ -816,6 +831,8 @@
document.getElementById('set-deletion-password-title').textContent = translations.set_deletion_password_title;
document.getElementById('deletion-password').placeholder = translations.deletion_password_placeholder;
document.getElementById('set-deletion-password-button').textContent = translations.set_deletion_password_button;
document.getElementById('use-trash-label').textContent = translations.use_trash_label;
document.getElementById('use-trash-all-label').textContent = translations.use_trash_all_label;
}
// Deletion Modal
@@ -1186,7 +1203,8 @@
function renameCurrentList(event) {
event.preventDefault();
if (!currentListId) return;
const currentList = allLists.find(l => l.id === currentListId);
useTrashSwitch.checked = currentList.use_trash;
newListNameInput.value = currentListName;
renameListError.textContent = '';
renameListModal.show();
@@ -1235,7 +1253,7 @@
const response = await fetch(`/api/lists/${currentListId}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ name: newName })
body: JSON.stringify({ name: newName, use_trash: useTrashSwitch.checked })
});
if (response.status === 401) return handleLogout();
@@ -1671,6 +1689,31 @@
confirmRenameBtn.addEventListener('click', handleConfirmRename);
confirmDeleteListBtn.addEventListener('click', handleConfirmDeleteList);
useTrashAllSwitch.addEventListener('change', async (e) => {
const isEnabled = e.target.checked;
const statusEl = document.getElementById('use-trash-all-status');
statusEl.textContent = '';
const response = await fetch('/api/settings/all-lists-use-trash', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ enabled: isEnabled })
});
if (response.status === 401) return handleLogout();
if (response.ok) {
statusEl.textContent = translations.notification_settings_saved;
statusEl.className = 'mt-2 text-success';
setTimeout(() => { statusEl.textContent = ''; }, 3000);
await fetchListsAndSetCurrent();
} else {
const data = await response.json();
statusEl.textContent = `Error: ${data.detail}`;
statusEl.className = 'mt-2 text-danger';
}
});
const toggleDeletionPassword = document.getElementById('toggle-deletion-password');
toggleDeletionPassword.addEventListener('click', () => {
@@ -1890,4 +1933,4 @@
</body>
</html>
</html>

View File

@@ -1,87 +1,75 @@
import xml.etree.ElementTree as ET
from typing import List, Dict
import xml.etree.ElementTree as ET
def get_standard_items(lang: str) -> List[str]:
"""
Parses the strings.xml file for the given language and returns the
standard shopping list items.
"""
if lang == "de":
filepath = "translations/de/strings.xml"
else:
filepath = "translations/en/strings.xml"
try:
tree = ET.parse(filepath)
if lang not in ['de', 'en']:
lang = 'de' # Fallback to German
tree = ET.parse(f'translations/{lang}/strings.xml')
root = tree.getroot()
for string_array in root.findall('string-array'):
if string_array.get('name') == 'standard_list_items':
return [item.text for item in string_array.findall('item')]
except (ET.ParseError, FileNotFoundError):
items = []
for string_array in root.findall(".//string-array[@name='standard_list_items']"):
for item in string_array.findall('item'):
if item.text:
items.append(item.text)
return items
except (FileNotFoundError, ET.ParseError) as e:
print(f"Error reading standard items for language '{lang}': {e}")
return []
return []
def get_all_translations(lang: str) -> Dict[str, str]:
"""
Returns a dictionary with all UI and notification translations
for the web app.
"""
if lang == "de":
if lang not in ['de', 'en']:
lang = 'de'
if lang == 'de':
return {
"title": "Geteilte Einkaufs/Aufgabenliste",
"placeholder": "Artikel eingeben...",
"title": "Geteilte Einkaufsliste",
"placeholder": "Neuer Eintrag, mehrere mit Komma getrennt",
"add_button": "Hinzufügen",
"add_one_button": "+1",
"notify_button": "Benachrichtigen",
"sending_notification": "Sende...",
"notification_sent": "Benachrichtigung gesendet!",
"notification_title": "Einkaufsliste aktualisiert",
"empty_list_message": "Die Einkaufsliste ist leer.",
"db_version": "Datenbankversion: {version}",
"db_error": "Datenbankfehler: {error}",
"generic_notification_error": "Fehler beim Senden der Benachrichtigung.",
"example_button_text": "Beispiel",
"info_text_existing": "= ist schon in der Liste",
"info_text_new": "= noch nicht in der Liste",
"admin_panel_title": "Admin Panel",
"create_user_title": "Neuen Benutzer erstellen",
"username_label": "Benutzername",
"password_label": "Passwort",
"is_admin_label": "Ist Admin?",
"create_user_button": "Benutzer erstellen",
"change_password_title": "Passwort ändern",
"current_password_label": "Aktuelles Passwort",
"new_password_label": "Neues Passwort",
"confirm_password_label": "Neues Passwort bestätigen",
"change_password_button": "Passwort ändern",
"manage_users_title": "Benutzer verwalten",
"delete_button": "Löschen",
"logout_button": "Abmelden",
"notify_button": "Änderungen abschließen & Benachrichtigen",
"login_title": "Anmelden",
"login_username_label": "Benutzername",
"login_password_label": "Passwort",
"login_button": "Anmelden",
"user_created_success": "Benutzer '{username}' erfolgreich erstellt.",
"login_error_incorrect": "Benutzername oder Passwort inkorrekt.",
"login_error_incorrect": "Falscher Benutzername oder Passwort.",
"login_error_generic": "Ein Fehler ist aufgetreten. Bitte erneut versuchen.",
"logout_button": "Abmelden",
"admin_panel_title": "Admin-Panel",
"create_user_title": "Neuen Benutzer anlegen",
"username_label": "Benutzername",
"password_label": "Passwort",
"is_admin_label": "Ist Admin",
"create_user_button": "Benutzer anlegen",
"user_created_success": "Benutzer '{username}' erfolgreich angelegt.",
"change_password_title": "Passwort ändern",
"current_password_label": "Aktuelles Passwort",
"new_password_label": "Neues Passwort",
"confirm_password_label": "Passwort bestätigen",
"change_password_button": "Passwort ändern",
"manage_users_title": "Benutzer verwalten",
"delete_button": "Löschen",
"db_version": "Datenbankversion: {version}",
"db_error": "Datenbankfehler: {error}",
"sending_notification": "Sende...",
"notification_sent": "Benachrichtigung gesendet!",
"generic_notification_error": "Fehler beim Senden der Benachrichtigung.",
"empty_list_message": "Die Einkaufsliste ist leer.",
"notification_title": "Einkaufsliste Aktualisiert",
"set_deletion_password_title": "Löschpasswort festlegen",
"deletion_password_placeholder": "Löschpasswort (Optional)",
"deletion_password_placeholder": "Löschpasswort (leer lassen zum Deaktivieren)",
"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",
"select_list_placeholder": "Liste auswählen",
"password_required": "Passwort erforderlich.",
"delete_password_label": "Bitte geben Sie das Löschpasswort ein.",
"incorrect_password": "Falsches Passwort.",
"generic_error": "Ein Fehler ist aufgetreten.",
"admin_permission_required": "Administratorberechtigung zum Löschen von Elementen erforderlich.",
"notification_send_list_only_label": "Nur Listennamen mitsenden",
"notification_settings_saved": "Einstellung gespeichert.",
"cancel_button": "Abbrechen",
"info_text_existing": "Artikel bereits in der Liste",
"info_text_new": "Neuer Artikel",
"example_button_text": "Beispiel",
"select_list_placeholder": "Liste auswählen",
"delete_list_modal_title": "Liste löschen",
"delete_list_confirm_text": "Sind Sie sicher, dass Sie die Liste \"{listName}\" löschen möchten? Alle zugehörigen Artikel werden ebenfalls entfernt.",
"delete_list_menu_item": "Aktuelle löschen",
@@ -93,60 +81,59 @@ def get_all_translations(lang: str) -> Dict[str, str]:
"no_list_to_restore_to": "Keine Liste zum Wiederherstellen vorhanden.",
"permanent_delete_modal_title": "Endgültig löschen",
"permanent_delete_confirm_text": "Sind Sie sicher, dass Sie die markierten Elemente endgültig löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"permanent_delete_button": "Endgültig löschen"
"permanent_delete_button": "Endgültig löschen",
"trash_search_placeholder": "Papierkorb durchsuchen...",
"restore_to_list": "Wiederherstellen nach: {listName}",
"use_trash_label": "Papierkorb für diese Liste verwenden",
"use_trash_all_label": "Papierkorb für alle Listen verwenden"
}
else: # Fallback to English
return {
"title": "Shared Shopping List",
"placeholder": "Enter item...",
"placeholder": "New item, separate multiple with comma",
"add_button": "Add",
"add_one_button": "+1",
"notify_button": "Notify",
"sending_notification": "Sending...",
"notification_sent": "Notification sent!",
"notification_title": "Shopping List Updated",
"empty_list_message": "The shopping list is empty.",
"db_version": "Database version: {version}",
"db_error": "Database error: {error}",
"generic_notification_error": "Error sending notification.",
"example_button_text": "Example",
"info_text_existing": "= is already on the list",
"info_text_new": "= not yet on the list",
"admin_panel_title": "Admin Panel",
"create_user_title": "Create New User",
"username_label": "Username",
"password_label": "Password",
"is_admin_label": "Is Admin?",
"create_user_button": "Create User",
"change_password_title": "Change Your Password",
"current_password_label": "Current Password",
"new_password_label": "New Password",
"confirm_password_label": "Confirm New Password",
"change_password_button": "Change Password",
"manage_users_title": "Manage Users",
"delete_button": "Delete",
"logout_button": "Logout",
"notify_button": "Finalize Changes & Notify",
"login_title": "Login",
"login_username_label": "Username",
"login_password_label": "Password",
"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.",
"logout_button": "Logout",
"admin_panel_title": "Admin Panel",
"create_user_title": "Create New User",
"username_label": "Username",
"password_label": "Password",
"is_admin_label": "Is Admin",
"create_user_button": "Create User",
"user_created_success": "User '{username}' created successfully.",
"change_password_title": "Change Password",
"current_password_label": "Current Password",
"new_password_label": "New Password",
"confirm_password_label": "Confirm Password",
"change_password_button": "Change Password",
"manage_users_title": "Manage Users",
"delete_button": "Delete",
"db_version": "Database version: {version}",
"db_error": "Database error: {error}",
"sending_notification": "Sending...",
"notification_sent": "Notification sent!",
"generic_notification_error": "Error sending notification.",
"empty_list_message": "The shopping list is empty.",
"notification_title": "Shopping List Updated",
"set_deletion_password_title": "Set Deletion Password",
"deletion_password_placeholder": "Deletion Password (Optional)",
"deletion_password_placeholder": "Deletion password (leave empty to disable)",
"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",
"select_list_placeholder": "Select List",
"password_required": "Password required.",
"delete_password_label": "Please enter the deletion password.",
"incorrect_password": "Incorrect password.",
"generic_error": "An error occurred.",
"admin_permission_required": "Admin permission required to delete items.",
"notification_send_list_only_label": "Send list name only",
"notification_settings_saved": "Setting saved.",
"cancel_button": "Cancel",
"info_text_existing": "Item already in list",
"info_text_new": "New item",
"example_button_text": "Example",
"select_list_placeholder": "Select list",
"delete_list_modal_title": "Delete List",
"delete_list_confirm_text": "Are you sure you want to delete the list \"{listName}\"? All associated items will also be removed.",
"delete_list_menu_item": "Delete current",
@@ -158,5 +145,9 @@ def get_all_translations(lang: str) -> Dict[str, str]:
"no_list_to_restore_to": "No list to restore to.",
"permanent_delete_modal_title": "Permanently delete",
"permanent_delete_confirm_text": "Are you sure you want to permanently delete the marked items? This action cannot be undone.",
"permanent_delete_button": "Permanently delete"
"permanent_delete_button": "Permanently delete",
"trash_search_placeholder": "Search in trash...",
"restore_to_list": "Restore to: {listName}",
"use_trash_label": "Use trash for this list",
"use_trash_all_label": "Use trash for all lists"
}

View File

@@ -116,4 +116,6 @@
<string name="restore_to_list">Wiederherstellen nach: {listName}</string>
<string name="no_list_to_restore_to">Keine Liste zum Wiederherstellen ausgewählt.</string>
<string name="restore_item_error">Fehler beim Wiederherstellen des Elements.</string>
</resources>
<string name="use_trash_label">Papierkorb für diese Liste verwenden</string>
<string name="use_trash_all_label">Papierkorb für alle Listen verwenden</string>
</resources>

View File

@@ -116,4 +116,6 @@
<string name="restore_to_list">Restore to: {listName}</string>
<string name="no_list_to_restore_to">No list selected for restore.</string>
<string name="restore_item_error">Error restoring item.</string>
</resources>
<string name="use_trash_label">Use trash for this list</string>
<string name="use_trash_all_label">Use trash for all lists</string>
</resources>