fix: Resolve database integrity and locking issues

This commit addresses critical database errors that occurred during item deletion and restoration.

- **IntegrityError Fix**:
  - The  and  functions were updated to prevent  errors.
  - The logic now checks for existing items by name in the destination list (trash or shopping list) before moving them, avoiding conflicts.

- **OperationalError Mitigation**:
  - To address  errors caused by concurrent writes, the SQLite connection timeout was increased to 30 seconds for all database connections. This provides more time for transactions to complete.
This commit is contained in:
2026-01-01 11:16:28 +01:00
parent 3fd13829fd
commit 6afefd0ec9
2 changed files with 82 additions and 61 deletions

View File

@@ -1,5 +1,11 @@
Changelog for Noteshop
## 01.01.2026
- **Bug Fixes**:
- Fixed a `UNIQUE constraint failed` error that occurred when deleting or restoring items that already existed in the target list (e.g., trash or another shopping list).
- Increased the database connection timeout to prevent `database is locked` errors during concurrent requests.
## [Unreleased]
## 24.12.2025

137
main.py
View File

@@ -174,7 +174,7 @@ manager = ConnectionManager()
# --- Auth Helper Functions ---
def get_user(username: str):
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
@@ -252,7 +252,7 @@ def column_exists(cursor, table_name, column_name):
def init_db():
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
# --- User Table ---
@@ -427,7 +427,7 @@ async def get_translations_route(lang: str):
@app.get("/api/lists", response_model=List[ShoppingList])
async def get_lists(current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
@@ -439,7 +439,7 @@ async def get_lists(current_user: User = Depends(get_current_active_user)):
@app.post("/api/lists", response_model=ShoppingList)
async def create_list(list_data: ShoppingListCreate, current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
# Get global trash setting
@@ -485,7 +485,7 @@ async def create_list(list_data: ShoppingListCreate, current_user: User = Depend
@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)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
cursor.execute(
@@ -513,7 +513,7 @@ async def rename_list(list_id: int, list_data: ShoppingListCreate, current_user:
@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)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
cursor.execute("SELECT name FROM shopping_lists WHERE id = ?", (list_id,))
@@ -560,7 +560,7 @@ async def login_for_access_token(request: Request, form_data: OAuth2PasswordRequ
detail="Account is locked due to too many failed login attempts."
)
else:
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
cursor.execute("UPDATE users SET is_locked = 0, failed_login_attempts = 0, locked_at = NULL WHERE username = ?",
(user.username,))
@@ -570,7 +570,7 @@ async def login_for_access_token(request: Request, form_data: OAuth2PasswordRequ
if not user or not verify_password(form_data.password, user.password_hash):
if user:
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
new_attempts = user.failed_login_attempts + 1
max_attempts = MAX_FAILED_ATTEMPTS_ADMIN if user.is_admin else MAX_FAILED_ATTEMPTS_USER
@@ -595,7 +595,7 @@ async def login_for_access_token(request: Request, form_data: OAuth2PasswordRequ
)
if user.failed_login_attempts > 0 or user.is_locked:
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
cursor.execute("UPDATE users SET failed_login_attempts = 0, is_locked = 0, locked_at = NULL WHERE username = ?",
(user.username,))
@@ -622,7 +622,7 @@ async def create_user(user: NewUser, current_user: User = Depends(get_current_ac
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can create users")
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
hashed_password = pwd_context.hash(user.password)
@@ -669,7 +669,7 @@ async def change_password(
new_hashed_password = pwd_context.hash(request.new_password)
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
cursor.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
@@ -698,7 +698,7 @@ async def update_email(
detail="Invalid email format.",
)
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
try:
# Use the email from the request, or None if it's an empty string
@@ -760,7 +760,7 @@ async def get_users(current_user: UserInDB = Depends(get_current_active_user)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can access the user list")
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
@@ -783,7 +783,7 @@ async def delete_user(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Admins cannot delete themselves")
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
cursor.execute("SELECT id FROM users WHERE id = ?", (user_id,))
@@ -805,7 +805,7 @@ async def get_locked_users(current_user: UserInDB = Depends(get_current_active_u
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can access this resource")
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
@@ -821,7 +821,7 @@ async def unlock_user(user_id: int, current_user: UserInDB = Depends(get_current
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can perform this action")
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
cursor.execute(
"UPDATE users SET is_locked = 0, failed_login_attempts = 0, locked_at = NULL WHERE id = ?", (user_id,))
@@ -833,7 +833,7 @@ async def unlock_user(user_id: int, current_user: UserInDB = Depends(get_current
@app.post("/api/users/forgot-password")
async def forgot_password(request: Request, forgot_req: ForgotPasswordRequest):
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
# Determine language from request header
@@ -899,7 +899,7 @@ async def forgot_password(request: Request, forgot_req: ForgotPasswordRequest):
@app.post("/api/users/reset-password")
async def reset_password(request: ResetPasswordRequest):
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
cursor.execute(
@@ -948,7 +948,7 @@ async def reset_password(request: ResetPasswordRequest):
@app.get("/api/items")
async def get_items(list_id: int, current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
@@ -960,7 +960,7 @@ async def get_items(list_id: int, current_user: User = Depends(get_current_activ
@app.post("/api/items")
async def add_item(item: Item, current_user: UserInDB = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
added_count = 0
@@ -994,7 +994,7 @@ async def add_item(item: Item, current_user: UserInDB = Depends(get_current_acti
@app.put("/api/items/{item_id}")
async def update_item(item_id: int, item: ItemUpdate, current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
try:
cursor.execute("UPDATE items SET name = ? WHERE id = ?",
@@ -1013,7 +1013,7 @@ async def update_item(item_id: int, item: ItemUpdate, current_user: User = Depen
@app.put("/api/items/{item_id}/mark")
async def mark_item(item_id: int, current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
cursor.execute(
"UPDATE items SET marked = NOT marked WHERE id = ?", (item_id,))
@@ -1025,7 +1025,7 @@ async def mark_item(item_id: int, current_user: User = Depends(get_current_activ
@app.post("/api/items/delete-marked")
async def delete_marked_items(request: DeletionRequest, current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
cursor.execute(
"SELECT value FROM app_settings WHERE key = 'deletion_password'")
@@ -1060,6 +1060,16 @@ async def delete_marked_items(request: DeletionRequest, current_user: User = Dep
use_trash_result = cursor.fetchone()
if use_trash_result and use_trash_result[0]:
# Move to trash and unmark
# First, delete items from the current list that already exist by name in the trash.
# This prevents UNIQUE constraint errors.
cursor.execute("""
DELETE FROM items
WHERE marked = 1 AND list_id = ? AND name IN (
SELECT name FROM items WHERE list_id = ?
)
""", (request.list_id, trash_list_id))
# Then, move the remaining marked items to the trash.
cursor.execute("UPDATE items SET list_id = ?, marked = 0 WHERE marked = 1 AND list_id = ?",
(trash_list_id, request.list_id))
else:
@@ -1075,7 +1085,7 @@ async def delete_marked_items(request: DeletionRequest, current_user: User = Dep
@app.get("/api/settings/deletion-password")
async def get_deletion_password(current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
cursor.execute(
"SELECT value FROM app_settings WHERE key = 'deletion_password'")
@@ -1090,7 +1100,7 @@ async def set_deletion_password(request: DeletionRequest, current_user: User = D
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can set the deletion password")
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
if not request.password:
@@ -1112,7 +1122,7 @@ class NotificationSendListOnlySetting(BaseModel):
@app.get("/api/settings/notification-send-list-only", response_model=NotificationSendListOnlySetting)
async def get_notification_send_list_only_setting(current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
cursor.execute(
"SELECT value FROM app_settings WHERE key = 'notification_send_list_only'")
@@ -1129,7 +1139,7 @@ async def set_notification_send_list_only_setting(request: NotificationSendListO
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can change this setting")
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
value_to_store = '1' if request.enabled else '0'
cursor.execute("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
@@ -1141,46 +1151,51 @@ async def set_notification_send_list_only_setting(request: NotificationSendListO
@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)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
# Check if target list exists
cursor.execute("SELECT id FROM shopping_lists WHERE id = ?",
(request.target_list_id,))
if not cursor.fetchone():
try:
# Check if target list exists
cursor.execute("SELECT id FROM shopping_lists WHERE id = ?", (request.target_list_id,))
if not cursor.fetchone():
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:
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 and get its name
cursor.execute("SELECT name, list_id FROM items WHERE id = ?", (item_id,))
item_data = cursor.fetchone()
if not item_data or item_data[1] != trash_list_id:
raise HTTPException(status_code=400, detail="Item is not in the trash bin.")
item_name = item_data[0]
# Check if an item with the same name exists in the target list
cursor.execute("SELECT id FROM items WHERE name = ? AND list_id = ?", (item_name, request.target_list_id))
if cursor.fetchone():
# If it exists, just delete the item from the trash, effectively merging it.
cursor.execute("DELETE FROM items WHERE id = ?", (item_id,))
else:
# If not, 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()
finally:
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)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
# Get the item's current list_id
@@ -1221,7 +1236,7 @@ async def delete_item(item_id: int, current_user: User = Depends(get_current_act
@app.post("/api/notify")
async def trigger_notification(list_id: int, list_name: str, lang: str = 'en', current_user: User = Depends(get_current_active_user)):
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
@@ -1264,7 +1279,7 @@ async def trigger_notification(list_id: int, list_name: str, lang: str = 'en', c
@app.get("/api/db-status")
async def get_db_status(current_user: User = Depends(get_current_active_user)):
try:
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
version = sqlite3.sqlite_version
conn.close()
return {"version": version, "error": None}
@@ -1303,7 +1318,7 @@ class UseTrashSetting(BaseModel):
@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)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
cursor.execute(
"SELECT value FROM app_settings WHERE key = 'global_use_trash'")
@@ -1320,7 +1335,7 @@ async def set_global_use_trash(request: UseTrashSetting, current_user: User = De
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can change this setting")
conn = sqlite3.connect(DB_FILE)
conn = sqlite3.connect(DB_FILE, timeout=30)
cursor = conn.cursor()
value_to_store_setting = '1' if request.enabled else '0'