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:
@@ -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
137
main.py
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user