Compare commits
16 Commits
634b041907
...
dcada2adfd
| Author | SHA1 | Date | |
|---|---|---|---|
| dcada2adfd | |||
| 26e7184c34 | |||
| 075662a8ad | |||
| 5b1a89d30a | |||
| e0d0e8c3df | |||
| 9a2f47dd78 | |||
| e7911b3d29 | |||
| dd6202e14d | |||
| d0689900f8 | |||
| ac78cb1f7c | |||
| f63991bf41 | |||
| 56893da0e2 | |||
| eb52ec8750 | |||
| 538a7a0d7b | |||
| 9671737d56 | |||
| 15853fd135 |
19
Changelog
19
Changelog
@@ -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.
|
||||
|
||||
17
README.md
17
README.md
@@ -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.
|
||||
|
||||
@@ -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
11
example.env
Normal 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
622
main.py
@@ -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)
|
||||
|
||||
@@ -4,4 +4,5 @@ requests
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==3.2.0
|
||||
python-jose[cryptography]
|
||||
python-multipart
|
||||
python-multipart
|
||||
fastapi-mail
|
||||
1079
static/index.html
1079
static/index.html
File diff suppressed because it is too large
Load Diff
276
translations.py
276
translations.py
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user