Compare commits

...

16 Commits

Author SHA1 Message Date
dcada2adfd feat: Implement robust password reset, unique emails, and improved UX
This comprehensive update introduces several key improvements and fixes across the application:

- **Enhanced Security (Unique Email Enforcement)**:
    - The  Pydantic model now supports an optional  field.
    - 's  function includes a migration to create a unique index on the  column in the punix table, ensuring no two non-empty email addresses are the same. This also includes graceful error handling for existing duplicate emails during migration.
    -  and  API endpoints now correctly handle  for duplicate email and username constraints, providing clear error messages to the user.

- **Improved Password Reset Functionality**:
    - **Frontend Token Handling**: Fixed an issue in  where the password reset token was incorrectly parsed from  instead of . The frontend routing logic ( function) was updated to correctly display the reset password view using .
    - **Visual Feedback**: Added a loading spinner and "Sending..." text to the "Send Reset Link" button in the "Forgot Password" view to enhance user experience during email dispatch.
    - **Backend Token Verification**: (Previously fixed in a separate commit, but related to the overall flow) Ensures the reset token verification is robust.

- **Working Password Change for Logged-in Users**:
    - Implemented the  function and integrated it with the  in . Users can now change their password while logged in, with client-side validation for password matching and length, and proper API interaction.

- **Localization Improvements**:
    - The  file was updated to include all newly introduced UI texts (e.g., for password change success/errors, loading states) in both German and English.
    - The "Forgot Password" confirmation message () was rephrased to a more professional and security-conscious wording in both languages, avoiding user enumeration.

- **Documentation & Configuration**:
    - Added an  file to serve as a template for configuration.
    - Updated  to explain the use of the  file.
    - Updated  with all recent changes.

These changes significantly enhance the application's security, user experience, and overall robustness, particularly concerning user management and authentication flows.
2025-12-24 01:27:49 +01:00
26e7184c34 feat: Implement robust password reset, unique emails, and improved UX
This comprehensive update introduces several key improvements and fixes across the application:

- **Enhanced Security (Unique Email Enforcement)**:
    - The  Pydantic model now supports an optional  field.
    - 's  function includes a migration to create a unique index on the  column in the punix table, ensuring no two non-empty email addresses are the same. This also includes graceful error handling for existing duplicate emails during migration.
    -  and  API endpoints now correctly handle  for duplicate email and username constraints, providing clear error messages to the user.

- **Improved Password Reset Functionality**:
    - **Frontend Token Handling**: Fixed an issue in  where the password reset token was incorrectly parsed from  instead of . The frontend routing logic ( function) was updated to correctly display the reset password view using .
    - **Visual Feedback**: Added a loading spinner and "Sending..." text to the "Send Reset Link" button in the "Forgot Password" view to enhance user experience during email dispatch.
    - **Backend Token Verification**: (Previously fixed in a separate commit, but related to the overall flow) Ensures the reset token verification is robust.

- **Working Password Change for Logged-in Users**:
    - Implemented the  function and integrated it with the  in . Users can now change their password while logged in, with client-side validation for password matching and length, and proper API interaction.

- **Localization Improvements**:
    - The  file was updated to include all newly introduced UI texts (e.g., for password change success/errors, loading states) in both German and English.
    - The "Forgot Password" confirmation message () was rephrased to a more professional and security-conscious wording in both languages, avoiding user enumeration.

These changes significantly enhance the application's security, user experience, and overall robustness, particularly concerning user management and authentication flows.
2025-12-24 00:41:42 +01:00
075662a8ad Fix: Frontend password reset token parsing and routing
This commit addresses the issue where the password reset page was not displayed correctly after clicking the reset link in the email.

- ****:
    - **Reset Token Parsing**: Changed the logic in the  function to extract the  from  instead of . The reset links send the token as part of the URL fragment, which  would not capture.
    - **View Routing Condition**: Updated the conditional checks for displaying the  to use  instead of a strict equality check (). This ensures that the view is correctly triggered when the hash contains additional query parameters, such as the reset token.

These changes ensure that users are correctly directed to the password reset form when they click the email link, allowing them to complete the password reset process.
2025-12-23 23:31:59 +01:00
5b1a89d30a Fix: Correct email configuration and password reset token verification
This commit addresses several issues related to email sending and password reset functionality:

- **docker-compose.yml**: Removed inline default values for MAIL_PORT, MAIL_STARTTLS, MAIL_SSL_TLS, and MAIL_SUPPRESS_SEND environment variables. This ensures that the application correctly uses the values provided in the .env file or environment. Previously, inline defaults could override intended settings, leading to unexpected behavior.

- **main.py**:
    - **MAIL_SUPPRESS_SEND Interpretation**: Corrected the boolean interpretation of the MAIL_SUPPRESS_SEND environment variable. Previously, bool("False") would evaluate to True, inadvertently suppressing email sending even when explicitly set to False. The fix ensures accurate evaluation.
    - **Email Sending Logging**: Added an INFO level log message before attempting to send a password reset email to provide clearer debugging information.
    - **Password Reset Token Verification**: Refactored the reset_password function's token verification logic. Instead of directly querying the database with a raw token (which would fail as tokens are stored hashed), the function now iterates through all stored reset tokens and uses pwd_context.verify to match the provided token against the hashed versions. This ensures secure and correct token validation. Additionally, the token expiration and deletion logic was refined.
2025-12-23 23:16:14 +01:00
e0d0e8c3df add email support for reset password 2025-12-23 01:04:48 +01:00
9a2f47dd78 fix(ui): Korrigiere E-Mail-Textfarbe im Dark Mode des Admin-Panels
Fügt eine CSS-Regel hinzu, um sicherzustellen, dass die E-Mail-Adressen der Benutzer, die mit der Klasse .text-muted angezeigt werden, im dunklen Thema eine helle, lesbare Farbe haben.
2025-12-23 00:59:51 +01:00
e7911b3d29 feat(ui): Ersetze Icons durch Emojis/SVG und vergrößere sie (Korrektur)
- Ersetzt die SVG-Icons für Einstellungen und Abmelden durch ein Emoji bzw. ein benutzerdefiniertes, farbiges SVG-Icon.
- Vergrößert alle Icons für Löschen, Papierkorb, Einstellungen und Abmelden für eine bessere Sichtbarkeit.
- Korrigiert die Größe des roten Papierkorb-Icons, das erscheint, wenn Artikel markiert werden.
2025-12-23 00:55:22 +01:00
dd6202e14d fix: Resolve application startup and frontend issues
This commit addresses several issues encountered during application startup and frontend rendering:

- **Backend (main.py):
  - Corrected ConnectionConfig parameters for fastapi_mail: Replaced deprecated MAIL_TLS/MAIL_SSL with MAIL_STARTTLS/MAIL_SSL_TLS and removed MAIL_CONSOLE_BACKEND (now handled by SUPPRESS_SEND).
  - Ensured 'templates/email' directory exists to resolve TEMPLATE_FOLDER validation error.
  - Replaced print() statements with logging.info()/logging.error() for email sending to improve log visibility in Docker.

- **Frontend (static/index.html):**
  - Implemented missing  and  JavaScript functions to fix a  that prevented proper frontend rendering.
  - Added corresponding event listeners for password reset forms and links.
  - Added missing  function for the admin user management.

These changes ensure the application starts without Pydantic validation errors, the frontend renders correctly, and email-related actions are properly logged in development.
2025-12-22 11:25:06 +01:00
d0689900f8 Fix: Default trash usage for lists when global setting is disabled
- Modified the  function: when the global trash usage is disabled in the admin panel, individual lists are now set to use the trash by default (use_trash = 1), instead of being disabled. This provides a better default and returns control to the user for individual list settings.
2025-12-22 09:46:30 +01:00
ac78cb1f7c Fix: Enforce global settings on startup and improve UI reactivity
- On application startup, the global trash setting is now checked and enforced across all existing lists to ensure a consistent state.
- The client-side WebSocket handler now refetches global settings upon receiving an update broadcast, ensuring that UI elements (like the visibility of the individual trash switch) react immediately to changes made by an administrator.
2025-12-22 09:33:50 +01:00
f63991bf41 Fix: Global trash setting persistence and UI issues
- Resolved issue where global trash usage setting was not persisted across container restarts by storing it in .
- Modified API endpoint for global trash setting from  to .
- Updated list creation logic to respect the global trash setting for new lists.
- Fixed "Method Not Allowed" error by updating frontend calls to use the new global trash endpoint.
- Ensured default list is only created if no other lists exist on fresh install or empty database.
- Corrected display logic for individual list trash switch:
  - Ensured  is fetched for all users and before rendering lists.
  - Disabled the individual trash switch in the main view if the global trash setting is active to prevent accidental changes.
  - Ensured the individual trash switch in the rename modal also respects the global setting.
2025-12-22 09:16:43 +01:00
56893da0e2 fix: Admin panel 'use trash for all lists' switch persistence
Correctly retrieve and set the state of the 'use trash for all lists' switch in the admin panel on page load. Previously, the switch would revert to 'off' after a refresh because its state was not fetched from the backend.

This is now fixed by introducing a getUseTrashAllSetting function that queries the backend for the global setting and sets the switch's state accordingly.
2025-12-22 01:13:26 +01:00
eb52ec8750 fix: Address all remaining UI and functionality bugs
- Correctly set the application title to 'Geteilte Einkaufs/Aufgabenliste'.
- Fix the missing label for the 'send notification without username' switch in the admin panel by adding the correct translation key.
- Widen the deletion password input field with a 'min-width' CSS rule to ensure the placeholder text is fully visible.
- Implement the 'Use Trash' switch in the list renaming modal for individual list settings.
- Implement a global 'Use Trash' switch in the admin panel to apply the setting to all lists.
- Add all necessary backend logic in 'main.py' to support the 'use_trash' functionality, including database migration, new API endpoints, and updated deletion logic.
- Add all necessary frontend logic in 'static/index.html' to handle the new switches and their interactions with the backend.
- Correctly translate all new UI elements.
2025-12-21 22:32:49 +01:00
538a7a0d7b 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.
2025-12-21 21:58:12 +01:00
9671737d56 feat: Implemented trash can view with search and restore functionality
- Added a search bar to the trash can view to filter items.
- Implemented the functionality to restore items from the trash can to the last selected list.
- The 'Restore to' text is now displayed in the trash can view, indicating the target list for restoration.
- The search bar and 'Restore to' text are now correctly styled in both light and dark themes.
- Ensured that all new UI elements are correctly translated.
2025-12-21 21:14:15 +01:00
15853fd135 feat(lists): enable list deletion for all users
This feature allows any authenticated user to delete a shopping list.

- **Backend:**
  - Removed the admin-only restriction from the `DELETE /api/lists/{list_id}` endpoint.
  - Removed the check that prevented the deletion of the last remaining list.
- **Frontend:**
  - Added a "Delete current" option to the list management dropdown menu.
  - Implemented a confirmation modal to prevent accidental deletion.
  - Added internationalization for all new UI elements.
2025-12-21 19:37:28 +01:00
10 changed files with 1803 additions and 249 deletions

View File

@@ -2,6 +2,25 @@ Changelog for Noteshop
## [Unreleased]
## 22.12.2025
- **Password Reset and Email Security**:
- Implemented a full password reset flow via email.
- Enforced unique email addresses for users to prevent account conflicts and improve security.
- Added an automatic email confirmation when a user updates their email address. If sending fails, the UI now informs the user to contact an administrator, helping to diagnose mail server configuration issues.
- **Improved User Experience (UX)**:
- Added loading spinners for asynchronous operations like sending password reset links and updating email addresses, providing better visual feedback.
- Reworded the "Forgot Password" confirmation message to be more professional and secure.
- **Bug Fixes**:
- Fixed a critical bug where `MAIL_SUPPRESS_SEND="False"` was incorrectly evaluated, preventing emails from being sent.
- Corrected the `docker-compose.yml` to properly use environment variables from a `.env` file for mail configuration.
- Fixed an issue where the password reset token was not correctly parsed from the URL, preventing the reset view from being displayed.
- Fixed a `SyntaxError` in the `translations.py` file caused by a missing brace.
- **Localization**:
- Added missing German and English translations for all new features and messages (e.g., password change success/errors, loading states).
- **Configuration**:
- Added an `example.env` file to provide a clear template for all required environment variables.
## 21.12.2025
- Configurable Notification Details:** Administrators can now configure whether to include the username and list name in Gotify notifications via a new switch in the admin panel.

View File

@@ -59,8 +59,25 @@ services:
- PGID=${PGID:-1000}
# Your Gotify URL for notifications. Should be set in a .env file.
- GOTIFY_URL=${GOTIFY_URL}
# E-Mail settings for password reset
- MAIL_USERNAME=${MAIL_USERNAME}
- MAIL_PASSWORD=${MAIL_PASSWORD}
- MAIL_FROM=${MAIL_FROM}
- MAIL_PORT=${MAIL_PORT}
- MAIL_SERVER=${MAIL_SERVER}
- MAIL_STARTTLS=${MAIL_STARTTLS}
- MAIL_SSL_TLS=${MAIL_SSL_TLS}
- MAIL_SUPPRESS_SEND=${MAIL_SUPPRESS_SEND}
```
### Environment Variables
All environment variables from the `docker-compose.yml` file (e.g., `GOTIFY_URL`, `MAIL_...`) can be managed in a `.env` file placed in the same directory.
An example file is provided in this repository. You can copy it to `.env` and adjust the values to your needs.
**TODO:** Add link to `example.env` here.
### First Login
After starting the container for the first time, a default administrator account is created so you can log in.

View File

@@ -19,4 +19,13 @@ services:
- PGID=${PGID:-1000}
# Your Gotify URL for notifications. Should be set in a .env file.
- GOTIFY_URL=${GOTIFY_URL}
# E-Mail settings for password reset
- MAIL_USERNAME=${MAIL_USERNAME}
- MAIL_PASSWORD=${MAIL_PASSWORD}
- MAIL_FROM=${MAIL_FROM}
- MAIL_PORT=${MAIL_PORT}
- MAIL_SERVER=${MAIL_SERVER}
- MAIL_STARTTLS=${MAIL_STARTTLS}
- MAIL_SSL_TLS=${MAIL_SSL_TLS}
- MAIL_SUPPRESS_SEND=${MAIL_SUPPRESS_SEND}

11
example.env Normal file
View File

@@ -0,0 +1,11 @@
GOTIFY_URL="example.gotify.com"
MAIL_USERNAME="your_email@example.com"
MAIL_PASSWORD="your_email_password"
MAIL_FROM="your_email@example.com"
MAIL_SERVER="smtp.example.com"
MAIL_PORT="587"
MAIL_STARTTLS="True"
MAIL_SSL_TLS="False"
MAIL_SUPPRESS_SEND="False"

622
main.py
View File

@@ -1,4 +1,7 @@
import secrets
import logging # Added this line
from fastapi_mail import FastMail, MessageSchema, ConnectionConfig
from collections import defaultdict
from passlib.context import CryptContext
import os
@@ -14,6 +17,10 @@ from translations import get_standard_items, get_all_translations
from fastapi import FastAPI, HTTPException, Depends, status, WebSocket, WebSocketDisconnect, Request
from fastapi.responses import HTMLResponse
logging.basicConfig(level=logging.INFO,
# Added this line
format='%(asctime)s - %(levelname)s - %(message)s')
app = FastAPI()
# --- Configuration ---
@@ -22,6 +29,37 @@ ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 43200 # 30 days
DB_FILE = "data/shoppinglist.db"
# --- Email Configuration ---
# For development, we'll use a console backend to print emails to stdout.
# In production, replace with actual SMTP settings from environment variables.
mail_starttls = os.getenv(
"MAIL_STARTTLS", "True").lower() in ('true', '1', 't')
mail_ssl_tls = os.getenv("MAIL_SSL_TLS", "False").lower() in ('true', '1', 't')
if mail_starttls and mail_ssl_tls:
raise ValueError(
"MAIL_STARTTLS and MAIL_SSL_TLS cannot both be True. Please check your .env file.")
conf = ConnectionConfig(
MAIL_USERNAME=os.getenv("MAIL_USERNAME", "your_email@example.com"),
MAIL_PASSWORD=os.getenv("MAIL_PASSWORD", "your_email_password"),
MAIL_FROM=os.getenv("MAIL_FROM", "your_email@example.com"),
MAIL_PORT=int(os.getenv("MAIL_PORT", 587)),
MAIL_SERVER=os.getenv("MAIL_SERVER", "smtp.example.com"),
MAIL_STARTTLS=mail_starttls,
MAIL_SSL_TLS=mail_ssl_tls,
USE_CREDENTIALS=os.getenv(
"USE_CREDENTIALS", "True").lower() in ('true', '1', 't'),
VALIDATE_CERTS=os.getenv(
"VALIDATE_CERTS", "True").lower() in ('true', '1', 't'),
TEMPLATE_FOLDER='./templates/email',
)
conf.SUPPRESS_SEND = os.getenv(
"MAIL_SUPPRESS_SEND", "False").lower() in ('true', '1', 't')
fm = FastMail(conf)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
@@ -46,6 +84,7 @@ class TokenData(BaseModel):
class User(BaseModel):
username: str
is_admin: bool
email: Optional[str] = None
class UserInDB(User):
@@ -54,6 +93,7 @@ class UserInDB(User):
failed_login_attempts: int
is_locked: bool
locked_at: Optional[datetime]
email: Optional[str] = None
class Item(BaseModel):
@@ -65,22 +105,43 @@ class ItemUpdate(BaseModel):
name: str
class ItemRestore(BaseModel):
target_list_id: int
class ShoppingList(BaseModel):
id: int
name: str
use_trash: bool
class ShoppingListCreate(BaseModel):
name: str
use_trash: Optional[bool] = None
class NewUser(BaseModel):
username: str
password: str
is_admin: bool = False
email: Optional[str] = None
class ForgotPasswordRequest(BaseModel):
email_or_username: str
class ResetPasswordRequest(BaseModel):
token: str
new_password: str
# --- WebSocket Connection Manager ---
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
@@ -116,7 +177,8 @@ def get_user(username: str):
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
cursor.execute(
"SELECT *, email FROM users WHERE username = ?", (username,))
user_data = cursor.fetchone()
conn.close()
if user_data:
@@ -199,15 +261,46 @@ def init_db():
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT 0
is_admin BOOLEAN NOT NULL DEFAULT 0,
email TEXT
)
""")
if not column_exists(cursor, "users", "failed_login_attempts"):
cursor.execute("ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0")
cursor.execute(
"ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0")
if not column_exists(cursor, "users", "is_locked"):
cursor.execute("ALTER TABLE users ADD COLUMN is_locked BOOLEAN NOT NULL DEFAULT 0")
cursor.execute(
"ALTER TABLE users ADD COLUMN is_locked BOOLEAN NOT NULL DEFAULT 0")
if not column_exists(cursor, "users", "locked_at"):
cursor.execute("ALTER TABLE users ADD COLUMN locked_at TIMESTAMP")
if not column_exists(cursor, "users", "email"):
cursor.execute("ALTER TABLE users ADD COLUMN email TEXT")
# Add a unique index for the email column, but only for non-null values
try:
cursor.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users (email) WHERE email IS NOT NULL AND email != ''")
except sqlite3.OperationalError as e:
if "duplicate" in str(e).lower():
print("\n" + "="*60)
print("FEHLER: Konnte keinen UNIQUE Index für E-Mails erstellen, da Duplikate in der Datenbank vorhanden sind.")
print(
"Bitte beheben Sie die doppelten E-Mail-Adressen manuell und starten Sie die Anwendung neu.")
print("Sie können ein Werkzeug wie 'DB Browser for SQLite' verwenden, um die Datei 'data/shoppinglist.db' zu bearbeiten.")
print("="*60 + "\n")
# We don't exit here, to allow the app to run so the user can potentially fix it via the UI if possible.
else:
raise e
# --- Password Reset Tokens Table ---
cursor.execute("""
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL
)
""")
# --- Schema Migration for 'items' table ---
cursor.execute("PRAGMA table_info(items)")
@@ -221,9 +314,14 @@ 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,
@@ -237,14 +335,17 @@ def init_db():
""")
# --- Data Migration from items_old ---
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='items_old'")
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='items_old'")
if cursor.fetchone():
cursor.execute("INSERT OR IGNORE INTO shopping_lists (name) VALUES ('Standard')")
default_list_id = cursor.execute("SELECT id FROM shopping_lists WHERE name = 'Standard'").fetchone()[0]
cursor.execute(
"INSERT OR IGNORE INTO shopping_lists (name) VALUES ('Standard')")
default_list_id = cursor.execute(
"SELECT id FROM shopping_lists WHERE name = 'Standard'").fetchone()[0]
cursor.execute("PRAGMA table_info(items_old)")
old_cols = {info[1] for info in cursor.fetchall()}
cols_to_select = ['id', 'name', 'is_standard']
cols_to_insert = ['id', 'name', 'is_standard']
if 'created_by_user_id' in old_cols:
@@ -258,11 +359,11 @@ def init_db():
insert_str = ", ".join(cols_to_insert + ['list_id'])
try:
cursor.execute(f"INSERT INTO items ({insert_str}) SELECT {select_str}, ? FROM items_old", (default_list_id,))
cursor.execute(
f"INSERT INTO items ({insert_str}) SELECT {select_str}, ? FROM items_old", (default_list_id,))
except sqlite3.IntegrityError:
print("Warnung: Beim Migrieren der Daten sind Duplikate aufgetreten. Einige Einträge wurden möglicherweise nicht migriert.")
cursor.execute("DROP TABLE items_old")
conn.commit()
print("Migration erfolgreich abgeschlossen.")
@@ -285,8 +386,24 @@ def init_db():
print("Default admin user 'admin' with password 'admin' created.")
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')")
# Ensure default list exists only if no other lists are present
cursor.execute("SELECT COUNT(*) FROM shopping_lists")
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')")
# Enforce global trash setting on startup
cursor.execute(
"SELECT value FROM app_settings WHERE key = 'global_use_trash'")
global_trash_setting = cursor.fetchone()
if global_trash_setting and global_trash_setting[0] == '1':
cursor.execute(
"UPDATE shopping_lists SET use_trash = 1 WHERE name != 'Papierkorb'")
conn.commit()
conn.close()
@@ -313,7 +430,8 @@ 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]
@@ -324,13 +442,20 @@ async def create_list(list_data: ShoppingListCreate, current_user: User = Depend
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
# Get global trash setting
cursor.execute(
"SELECT value FROM app_settings WHERE key = 'global_use_trash'")
global_trash_result = cursor.fetchone()
use_trash_default = global_trash_result[0] == '1' if global_trash_result else True
name_to_insert = list_data.name.strip()
if not name_to_insert:
# Find the highest existing "Standard(n)" name
cursor.execute("SELECT name FROM shopping_lists WHERE name LIKE 'Standard%' ORDER BY name DESC")
cursor.execute(
"SELECT name FROM shopping_lists WHERE name LIKE 'Standard%' ORDER BY name DESC")
last_standard = cursor.fetchone()
if not last_standard:
name_to_insert = "Standard"
name_to_insert = "Standard"
else:
last_name = last_standard[0]
if last_name == "Standard":
@@ -344,33 +469,71 @@ async def create_list(list_data: ShoppingListCreate, current_user: User = Depend
name_to_insert = f"{last_name}(1)"
try:
cursor.execute("INSERT INTO shopping_lists (name) VALUES (?)", (name_to_insert,))
cursor.execute("INSERT INTO shopping_lists (name, use_trash) VALUES (?, ?)",
(name_to_insert, use_trash_default))
new_list_id = cursor.lastrowid
conn.commit()
except sqlite3.IntegrityError:
conn.close()
raise HTTPException(status_code=400, detail="A list with this name already exists.")
raise HTTPException(
status_code=400, detail="A list with this name already exists.")
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": use_trash_default}
@app.put("/api/lists/{list_id}", response_model=ShoppingList)
async def rename_list(list_id: int, list_data: ShoppingListCreate, current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
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()
raise HTTPException(status_code=400, detail="A list with this name already exists.")
raise HTTPException(
status_code=400, detail="A list with this name already exists.")
finally:
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)
async def delete_list(list_id: int, current_user: User = Depends(get_current_active_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":
conn.close()
raise HTTPException(
status_code=400, detail="Cannot delete the trash bin.")
# Delete associated items first
cursor.execute("DELETE FROM items WHERE list_id = ?", (list_id,))
# Then delete the list
cursor.execute("DELETE FROM shopping_lists WHERE id = ?", (list_id,))
conn.commit()
conn.close()
await manager.broadcast_update()
return {"status": "ok"}
# --- API Endpunkte ---
@@ -462,19 +625,29 @@ async def create_user(user: NewUser, current_user: User = Depends(get_current_ac
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("SELECT id FROM users WHERE username = ?", (user.username,))
if cursor.fetchone():
conn.close()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered")
hashed_password = pwd_context.hash(user.password)
cursor.execute(
"INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)",
(user.username, hashed_password, user.is_admin)
)
conn.commit()
conn.close()
try:
cursor.execute(
"INSERT INTO users (username, password_hash, is_admin, email) VALUES (?, ?, ?, ?)",
(user.username, hashed_password, user.is_admin,
user.email if user.email else None)
)
conn.commit()
except sqlite3.IntegrityError as e:
conn.close()
if "UNIQUE constraint failed: users.username" in str(e):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Username already registered")
elif "UNIQUE constraint failed: users.email" in str(e):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered")
else:
# Generic message for other integrity errors
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="A database integrity error occurred.")
finally:
conn.close()
return {"status": f"User {user.username} created"}
@@ -508,11 +681,77 @@ async def change_password(
return {"status": "Password updated successfully"}
class EmailUpdateRequest(BaseModel):
email: str
@app.put("/api/users/me/email", status_code=status.HTTP_200_OK)
async def update_email(
request: Request,
update_req: EmailUpdateRequest,
current_user: UserInDB = Depends(get_current_active_user)
):
# Basic email validation
if "@" not in update_req.email and update_req.email != "":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid email format.",
)
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
try:
# Use the email from the request, or None if it's an empty string
email_to_store = update_req.email if update_req.email else None
cursor.execute(
"UPDATE users SET email = ? WHERE id = ?",
(email_to_store, current_user.id)
)
conn.commit()
except sqlite3.IntegrityError:
conn.close()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is already in use by another account.")
finally:
conn.close()
# After successfully saving, try to send a confirmation email if a new email was set
if email_to_store:
accept_language = request.headers.get("accept-language", "en")
lang = "de" if "de" in accept_language.split(",")[0] else "en"
translations = get_all_translations(lang)
email_body = f"""
<p>{translations.get('email_update_body_hello', 'Hello {username},').format(username=current_user.username)}</p>
<p>{translations.get('email_update_body_text', 'Your email address for the shopping list has been successfully updated to this address.')}</p>
"""
message = MessageSchema(
subject=translations.get(
'email_update_subject', 'Email Address Updated'),
recipients=[email_to_store],
body=email_body,
subtype="html"
)
try:
await fm.send_message(message)
except Exception as e:
# The email is saved, but sending the confirmation failed.
# We inform the user by raising an exception that the frontend can display.
logging.error(f"Failed to send confirmation email to {email_to_store}: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=translations.get("email_update_send_fail", "Email address saved, but failed to send confirmation email. Please contact the administrator to check mail server configuration."))
return {"status": "Email updated successfully"}
class UserListUser(BaseModel):
id: int
username: str
is_admin: bool
is_locked: bool
email: Optional[str] = None
@app.get("/api/users", response_model=List[UserListUser])
@@ -524,7 +763,8 @@ async def get_users(current_user: UserInDB = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT id, username, is_admin, is_locked FROM users")
cursor.execute(
"SELECT id, username, is_admin, is_locked, email FROM users")
users = cursor.fetchall()
conn.close()
return [dict(row) for row in users]
@@ -569,7 +809,7 @@ async def get_locked_users(current_user: UserInDB = Depends(get_current_active_u
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
"SELECT id, username, is_admin, is_locked FROM users WHERE is_locked = 1")
"SELECT id, username, is_admin, is_locked, email FROM users WHERE is_locked = 1")
locked_users = cursor.fetchall()
conn.close()
return [dict(row) for row in locked_users]
@@ -591,6 +831,121 @@ async def unlock_user(user_id: int, current_user: UserInDB = Depends(get_current
return {"status": "User unlocked successfully"}
@app.post("/api/users/forgot-password")
async def forgot_password(request: Request, forgot_req: ForgotPasswordRequest):
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
# Determine language from request header
accept_language = request.headers.get("accept-language", "en")
lang = "de" if "de" in accept_language.split(",")[0] else "en"
translations = get_all_translations(lang)
user_info = None
# Try to find user by email
cursor.execute("SELECT id, username, email FROM users WHERE email = ?",
(forgot_req.email_or_username,))
user_info = cursor.fetchone()
# If not found by email, try by username
if not user_info:
cursor.execute("SELECT id, username, email FROM users WHERE username = ?",
(forgot_req.email_or_username,))
user_info = cursor.fetchone()
if not user_info or not user_info[2]: # user_info[2] is the email
conn.close()
return {"message": translations.get("forgot_password_email_sent", "If a matching user with an email address was found, a password reset email has been sent.")}
user_id, username, email = user_info
token = secrets.token_urlsafe(32)
hashed_token = pwd_context.hash(token)
expires_at = datetime.utcnow() + timedelta(hours=1)
cursor.execute("INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
(user_id, hashed_token, expires_at))
conn.commit()
conn.close()
reset_link = f"{request.url.scheme}://{request.url.hostname}:{request.url.port}/#reset-password?token={token}"
email_body = f"""
<p>{translations.get('forgot_password_email_body_hello', 'Hello {username},').format(username=username)}</p>
<p>{translations.get('forgot_password_email_body_request_text', 'You have requested a password reset for your account.')}</p>
<p>{translations.get('forgot_password_email_body_link_text', 'Please click on the following link to reset your password:')}</p>
<p><a href="{reset_link}">{reset_link}</a></p>
<p>{translations.get('forgot_password_email_body_expiry_text', 'This link is valid for 1 hour.')}</p>
<p>{translations.get('forgot_password_email_body_ignore_text', 'If you did not request a password reset, please ignore this email.')}</p>
"""
message = MessageSchema(
subject=translations.get(
'forgot_password_email_subject', 'Password Reset Request'),
recipients=[email],
body=email_body,
subtype="html"
)
try:
logging.info(f"Attempting to send password reset email to {email}")
await fm.send_message(message)
logging.info(f"Password reset email sent to {email}")
except Exception as e:
logging.error(f"Failed to send email: {e}")
return {"message": translations.get("forgot_password_email_sent", "If a matching user with an email address was found, a password reset email has been sent.")}
@app.post("/api/users/reset-password")
async def reset_password(request: ResetPasswordRequest):
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute(
"SELECT user_id, expires_at, token FROM password_reset_tokens")
all_tokens = cursor.fetchall()
reset_data = None
for user_id, expires_at_str, hashed_stored_token in all_tokens:
try:
if pwd_context.verify(request.token, hashed_stored_token):
reset_data = (user_id, expires_at_str, hashed_stored_token)
break
except Exception:
continue
if not reset_data:
conn.close()
raise HTTPException(
status_code=400, detail="Invalid or expired reset token.")
user_id, expires_at_str, hashed_stored_token = reset_data
expires_at = datetime.fromisoformat(expires_at_str)
if datetime.utcnow() > expires_at:
# It's important to also delete the expired token from the database
cursor.execute(
"DELETE FROM password_reset_tokens WHERE token = ?", (hashed_stored_token,))
conn.commit()
conn.close()
raise HTTPException(
status_code=400, detail="Invalid or expired reset token.")
# Update user password
new_hashed_password = pwd_context.hash(request.new_password)
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?",
(new_hashed_password, user_id))
# Delete used token
cursor.execute(
"DELETE FROM password_reset_tokens WHERE token = ?", (hashed_stored_token,))
conn.commit()
conn.close()
return {"message": "Password has been reset successfully."}
@app.get("/api/items")
async def get_items(list_id: int, current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
@@ -610,7 +965,8 @@ async def add_item(item: Item, current_user: UserInDB = Depends(get_current_acti
added_count = 0
for name in item.names:
cursor.execute("SELECT id FROM items WHERE name = ? AND list_id = ?", (name, item.list_id))
cursor.execute(
"SELECT id FROM items WHERE name = ? AND list_id = ?", (name, item.list_id))
if cursor.fetchone():
continue
@@ -641,7 +997,8 @@ async def update_item(item_id: int, item: ItemUpdate, current_user: User = Depen
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
try:
cursor.execute("UPDATE items SET name = ? WHERE id = ?", (item.name, item_id))
cursor.execute("UPDATE items SET name = ? WHERE id = ?",
(item.name, item_id))
conn.commit()
except sqlite3.IntegrityError:
conn.close()
@@ -658,7 +1015,8 @@ async def update_item(item_id: int, item: ItemUpdate, current_user: User = Depen
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,))
cursor.execute(
"UPDATE items SET marked = NOT marked WHERE id = ?", (item_id,))
conn.commit()
await manager.broadcast_update()
conn.close()
@@ -672,23 +1030,46 @@ async def delete_marked_items(request: DeletionRequest, current_user: User = Dep
cursor.execute(
"SELECT value FROM app_settings WHERE key = 'deletion_password'")
result = cursor.fetchone()
conn.close()
# If a deletion password is set in app_settings
if result:
# And no password was provided in the request, or the provided password is incorrect
if not request.password or not pwd_context.verify(request.password, result[0]):
conn.close()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect password")
# If no deletion password is set in app_settings, then no password is required for deletion.
# The request.password is ignored in this case.
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("DELETE FROM items WHERE marked = 1 AND list_id = ?", (request.list_id,))
# Get Papierkorb list ID
cursor.execute("SELECT id FROM shopping_lists WHERE name = 'Papierkorb'")
trash_list_id_result = cursor.fetchone()
if not trash_list_id_result:
# This should not happen if init_db is correct
conn.close()
raise HTTPException(
status_code=500, detail="Trash bin list not found.")
trash_list_id = trash_list_id_result[0]
if request.list_id == trash_list_id:
# Permanent deletion from trash
cursor.execute(
"DELETE FROM items WHERE marked = 1 AND list_id = ?", (trash_list_id,))
else:
# 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()
await manager.broadcast_update()
conn.close()
await manager.broadcast_update()
return {"status": "ok"}
@@ -713,12 +1094,13 @@ async def set_deletion_password(request: DeletionRequest, current_user: User = D
cursor = conn.cursor()
if not request.password:
cursor.execute("DELETE FROM app_settings WHERE key = 'deletion_password'")
cursor.execute(
"DELETE FROM app_settings WHERE key = 'deletion_password'")
else:
hashed_password = pwd_context.hash(request.password)
cursor.execute("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
('deletion_password', hashed_password))
conn.commit()
conn.close()
return {"status": "ok"}
@@ -732,7 +1114,8 @@ class NotificationSendListOnlySetting(BaseModel):
async def get_notification_send_list_only_setting(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 = 'notification_send_list_only'")
cursor.execute(
"SELECT value FROM app_settings WHERE key = 'notification_send_list_only'")
result = cursor.fetchone()
conn.close()
# Default to disabled (send both user and list) if not set
@@ -743,26 +1126,93 @@ async def get_notification_send_list_only_setting(current_user: User = Depends(g
@app.post("/api/settings/notification-send-list-only")
async def set_notification_send_list_only_setting(request: NotificationSendListOnlySetting, 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")
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("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)", ('notification_send_list_only', value_to_store))
cursor.execute("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
('notification_send_list_only', value_to_store))
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:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can delete items")
@app.put("/api/items/{item_id}/restore")
async def restore_item(item_id: int, request: ItemRestore, current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
cursor.execute("DELETE FROM items WHERE id = ?", (item_id,))
# Check if target list exists
cursor.execute("SELECT id FROM shopping_lists WHERE id = ?",
(request.target_list_id,))
if not cursor.fetchone():
conn.close()
raise HTTPException(status_code=404, detail="Target list not found.")
# Get Papierkorb list ID
cursor.execute("SELECT id FROM shopping_lists WHERE name = 'Papierkorb'")
trash_list_id_result = cursor.fetchone()
if not trash_list_id_result:
conn.close()
raise HTTPException(
status_code=500, detail="Trash bin list not found.")
trash_list_id = trash_list_id_result[0]
# Check if item is in trash
cursor.execute("SELECT list_id FROM items WHERE id = ?", (item_id,))
item_list_id_result = cursor.fetchone()
if not item_list_id_result or item_list_id_result[0] != trash_list_id:
conn.close()
raise HTTPException(
status_code=400, detail="Item is not in the trash bin.")
# Restore to target list and unmark
cursor.execute("UPDATE items SET list_id = ?, marked = 0 WHERE id = ?",
(request.target_list_id, item_id))
conn.commit()
conn.close()
await manager.broadcast_update()
return {"status": "ok"}
@app.delete("/api/items/{item_id}")
async def delete_item(item_id: int, current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
cursor = conn.cursor()
# Get the item's current list_id
cursor.execute("SELECT list_id FROM items WHERE id = ?", (item_id,))
item_list_id_result = cursor.fetchone()
if not item_list_id_result:
conn.close()
raise HTTPException(status_code=404, detail="Item not found.")
item_list_id = item_list_id_result[0]
# Get Papierkorb list ID
cursor.execute("SELECT id FROM shopping_lists WHERE name = 'Papierkorb'")
trash_list_id_result = cursor.fetchone()
if not trash_list_id_result:
conn.close()
raise HTTPException(
status_code=500, detail="Trash bin list not found.")
trash_list_id = trash_list_id_result[0]
# 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
cursor.execute(
"UPDATE items SET list_id = ?, marked = 0 WHERE id = ?", (trash_list_id, item_id))
conn.commit()
conn.close()
await manager.broadcast_update()
@@ -776,12 +1226,14 @@ async def trigger_notification(list_id: int, list_name: str, lang: str = 'en', c
cursor = conn.cursor()
# Get the notification detail setting
cursor.execute("SELECT value FROM app_settings WHERE key = 'notification_send_list_only'")
cursor.execute(
"SELECT value FROM app_settings WHERE key = 'notification_send_list_only'")
setting_result = cursor.fetchone()
# Default to False (send both) if not set in the database
send_list_only = setting_result[0] == '1' if setting_result else False
cursor.execute("SELECT name FROM items WHERE list_id = ? ORDER BY name", (list_id,))
cursor.execute(
"SELECT name FROM items WHERE list_id = ? ORDER BY name", (list_id,))
items = cursor.fetchall()
conn.close()
@@ -794,14 +1246,14 @@ async def trigger_notification(list_id: int, list_name: str, lang: str = 'en', c
else:
message = ", ".join(item_names)
notification_title = translations.get("notification_title", "Shopping List Updated")
notification_title = translations.get(
"notification_title", "Shopping List Updated")
if send_list_only:
full_title = f"{list_name}: {notification_title}"
else:
full_title = f"{current_user.username} @ {list_name}: {notification_title}"
status = send_gotify_notification(
full_title,
message
@@ -845,6 +1297,48 @@ async def websocket_endpoint(websocket: WebSocket, token: str):
await manager.broadcast_user_list()
class UseTrashSetting(BaseModel):
enabled: bool
@app.get("/api/settings/global-use-trash", response_model=UseTrashSetting)
async def get_global_use_trash(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 = 'global_use_trash'")
result = cursor.fetchone()
conn.close()
# Default to True if not set
is_enabled = result[0] == '1' if result else True
return {"enabled": is_enabled}
@app.post("/api/settings/global-use-trash")
async def set_global_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_setting = '1' if request.enabled else '0'
cursor.execute("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
('global_use_trash', value_to_store_setting))
# When the admin toggles the global setting, all lists should default to using the trash.
# If the global setting is enabled, this is enforced.
# If it's disabled, lists are reset to the default 'on' state, giving control back to the user.
cursor.execute(
"UPDATE shopping_lists SET use_trash = 1 WHERE name != 'Papierkorb'")
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

@@ -4,4 +4,5 @@ requests
passlib[bcrypt]==1.7.4
bcrypt==3.2.0
python-jose[cryptography]
python-multipart
python-multipart
fastapi-mail

File diff suppressed because it is too large Load Diff

View File

@@ -1,138 +1,226 @@
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)
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):
return []
if lang not in ['de', 'en']:
lang = 'de' # Fallback to German
return []
tree = ET.parse(f'translations/{lang}/strings.xml')
root = tree.getroot()
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 []
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...",
"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",
"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.",
"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",
"delete_list_error": "Beim Löschen der Liste ist ein Fehler aufgetreten.",
"rename_list_menu_item": "Aktuelle umbenennen",
"new_list_menu_item": "Neue Liste erstellen",
"trash_bin_list_name": "Papierkorb",
"restore_item_error": "Fehler beim Wiederherstellen des Artikels.",
"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",
"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",
"notification_send_list_only_label": "Nur Listennamen mitsenden",
"notification_settings_saved": "Einstellung gespeichert."
"forgot_password_email_subject": "Passwort zurücksetzen",
"forgot_password_email_body_hello": "Hallo {username},",
"forgot_password_email_body_request_text": "Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.",
"forgot_password_email_body_link_text": "Bitte klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:",
"forgot_password_email_body_expiry_text": "Dieser Link ist 1 Stunde lang gültig.",
"forgot_password_email_body_ignore_text": "Wenn Sie keine Passwortzurücksetzung beantragt haben, ignorieren Sie diese E-Mail bitte.",
"settings_button": "Einstellungen",
"settings_modal_label": "Einstellungen",
"update_email_title": "E-Mail aktualisieren",
"user_email_label": "E-Mail-Adresse",
"update_email_button": "E-Mail aktualisieren",
"email_update_success": "E-Mail erfolgreich aktualisiert.",
"forgot_password_link": "Passwort vergessen?",
"forgot_password_title": "Passwort vergessen",
"email_username_label": "E-Mail oder Benutzername",
"send_reset_link_button": "Link zum Zurücksetzen senden",
"back_to_login_link": "Zurück zum Login",
"reset_password_title": "Passwort zurücksetzen",
"set_new_password_button": "Neues Passwort festlegen",
"forgot_password_email_sent": "Falls ein Konto mit den angegebenen Daten und einer hinterlegten E-Mail-Adresse existiert, wurde ein Link zum Zurücksetzen des Passworts versendet.",
"unlock_user_button": "Entsperren",
"confirm_unlock_user": "Sind Sie sicher, dass Sie den Benutzer '{username}' entsperren möchten?",
"delete_user_button": "Löschen",
"confirm_delete_user": "Sind Sie sicher, dass Sie den Benutzer '{username}' löschen möchten?",
"generic_error": "Ein Fehler ist aufgetreten.",
"email_label": "E-Mail",
"change_password_success": "Passwort erfolgreich geändert.",
"reset_password_match_error": "Neue Passwörter stimmen nicht überein.",
"reset_password_length_error": "Passwort muss mindestens 6 Zeichen lang sein.",
"change_password_error": "Fehler beim Ändern des Passworts.",
"sending": "Senden...",
"email_update_subject": "E-Mail-Adresse aktualisiert",
"email_update_body_hello": "Hallo {username},",
"email_update_body_text": "Ihre E-Mail-Adresse für die Einkaufsliste wurde erfolgreich auf diese Adresse aktualisiert.",
"email_update_send_fail": "Die E-Mail-Adresse wurde gespeichert, aber die Bestätigungs-E-Mail konnte nicht gesendet werden. Bitte kontaktieren Sie den Administrator, um die E-Mail-Serverkonfiguration zu überprüfen."
}
else: # Fallback to English
return {
"title": "Shared Shopping List",
"placeholder": "Enter item...",
"title": "Shared Shopping/Task List",
"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",
"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.",
"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",
"delete_list_error": "An error occurred while deleting the list.",
"rename_list_menu_item": "Rename current",
"new_list_menu_item": "Create new list",
"trash_bin_list_name": "Trash Bin",
"restore_item_error": "An error occurred while restoring the item.",
"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",
"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",
"notification_send_list_only_label": "Send list name only",
"notification_settings_saved": "Setting saved."
"forgot_password_email_subject": "Password Reset Request",
"forgot_password_email_body_hello": "Hello {username},",
"forgot_password_email_body_request_text": "You have requested a password reset for your account.",
"forgot_password_email_body_link_text": "Please click on the following link to reset your password:",
"forgot_password_email_body_expiry_text": "This link is valid for 1 hour.",
"forgot_password_email_body_ignore_text": "If you did not request a password reset, please ignore this email.",
"settings_button": "Settings",
"settings_modal_label": "Settings",
"update_email_title": "Update Email",
"user_email_label": "Email Address",
"update_email_button": "Update Email",
"email_update_success": "Email updated successfully.",
"forgot_password_link": "Forgot password?",
"forgot_password_title": "Forgot Password",
"email_username_label": "Email or Username",
"send_reset_link_button": "Send Reset Link",
"back_to_login_link": "Back to Login",
"reset_password_title": "Reset Password",
"set_new_password_button": "Set New Password",
"forgot_password_email_sent": "If an account with the provided data and a stored email address exists, a password reset link has been sent.",
"unlock_user_button": "Unlock",
"confirm_unlock_user": "Are you sure you want to unlock {username}?",
"delete_user_button": "Delete", "confirm_delete_user": "Are you sure you want to delete user {username}?",
"generic_error": "An error occurred.",
"email_label": "Email",
"change_password_success": "Password changed successfully.",
"reset_password_match_error": "New passwords do not match.",
"reset_password_length_error": "Password must be at least 6 characters long.",
"change_password_error": "Error changing password.",
"sending": "Sending...",
"email_update_subject": "Email Address Updated",
"email_update_body_hello": "Hello {username},",
"email_update_body_text": "Your email address for the shopping list has been successfully updated to this address.",
"email_update_send_fail": "Email address saved, but failed to send confirmation email. Please contact the administrator to check mail server configuration."
}

View File

@@ -112,4 +112,10 @@
<string name="generic_error">Ein Fehler ist aufgetreten.</string>
<string name="notification_send_list_only_label">Nur Listennamen mitsenden</string>
<string name="notification_settings_saved">Einstellung gespeichert.</string>
</resources>
<string name="trash_search_placeholder">Papierkorb durchsuchen...</string>
<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>
<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

@@ -112,4 +112,10 @@
<string name="generic_error">An error occurred.</string>
<string name="notification_send_list_only_label">Send list name only</string>
<string name="notification_settings_saved">Setting saved.</string>
</resources>
<string name="trash_search_placeholder">Search in trash...</string>
<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>
<string name="use_trash_label">Use trash for this list</string>
<string name="use_trash_all_label">Use trash for all lists</string>
</resources>