-
Notifications
You must be signed in to change notification settings - Fork 587
Right-Click Context Menu Actions for Images *(Favorite, Copy, View Details)* #900
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
577e63e
85e0aa5
5311ecb
f8e00a6
2b495e8
ebc7229
6d88afe
fdec570
7e4d2eb
4315f5b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,15 @@ | ||
| from fastapi import APIRouter, HTTPException, Query, status | ||
| from typing import List, Optional | ||
| from app.database.images import db_get_all_images | ||
| from app.schemas.images import ErrorResponse | ||
| from app.utils.images import image_util_parse_metadata | ||
| from app.database.images import ( | ||
| db_get_all_images, | ||
| db_toggle_image_favourite_status, | ||
| db_get_image_by_id, | ||
| db_delete_images_by_ids, | ||
| db_add_to_deleted_images, | ||
| ) | ||
| from app.schemas.images import ErrorResponse, DeleteImageResponse | ||
| from app.utils.images import image_util_parse_metadata, image_util_delete_image_files | ||
| from pydantic import BaseModel | ||
| from app.database.images import db_toggle_image_favourite_status | ||
| from app.logging.setup_logging import get_logger | ||
|
|
||
| # Initialize logger | ||
|
|
@@ -128,3 +133,62 @@ class ImageInfoResponse(BaseModel): | |
| isTagged: bool | ||
| isFavourite: bool | ||
| tags: Optional[List[str]] = None | ||
|
|
||
|
|
||
| @router.delete( | ||
| "/{image_id}", | ||
| response_model=DeleteImageResponse, | ||
| responses={404: {"model": ErrorResponse}, 500: {"model": ErrorResponse}}, | ||
| ) | ||
| def delete_image(image_id: str): | ||
| """ | ||
| Explicitly delete an image from the library. | ||
| Steps: | ||
| 1. Fetch image record from DB. | ||
| 2. Add to 'deleted_images' tombstone (prevents re-sync). | ||
| 3. Delete physical files (original + thumbnail). | ||
| 4. Delete DB record (triggers cascaded delete for tags/metadata). | ||
| """ | ||
| try: | ||
| logger.info(f"Image deletion requested for ID: {image_id}") | ||
|
|
||
| # 1. Fetch image record | ||
| image = db_get_image_by_id(image_id) | ||
| if not image: | ||
| logger.warning(f"Image deletion failed: ID {image_id} not found") | ||
| raise HTTPException(status_code=404, detail="Image not found") | ||
|
|
||
| original_path = image.get("path") | ||
|
|
||
| # 2. Add to tombstone | ||
| db_add_to_deleted_images(original_path) | ||
|
|
||
| # 3. Delete files | ||
| # We do this before DB deletion to have the paths available, | ||
| # but if this fails, we still proceed with DB cleanup to keep it consistent. | ||
| image_util_delete_image_files(image) | ||
|
|
||
| # 4. Delete DB record | ||
| # This will trigger ON DELETE CASCADE for faces, album_images, image_classes | ||
| db_delete_images_by_ids([image_id]) | ||
|
|
||
| logger.info(f"Successfully deleted image {image_id} and path {original_path}") | ||
|
|
||
| return DeleteImageResponse( | ||
| success=True, | ||
| message="Image deleted successfully", | ||
| data=image_id, | ||
| ) | ||
|
|
||
| except HTTPException: | ||
| raise | ||
| except Exception as e: | ||
| logger.error(f"Error deleting image {image_id}: {e}") | ||
| raise HTTPException( | ||
| status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, | ||
| detail=ErrorResponse( | ||
| success=False, | ||
| error="Internal server error", | ||
| message=f"Failed to delete image: {str(e)}", | ||
| ).model_dump(), | ||
| ) | ||
|
Comment on lines
+138
to
+194
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Find the implementation of db_add_to_deleted_images
rg -n "def db_add_to_deleted_images" --type=py -A 10Repository: AOSSIE-Org/PictoPy Length of output: 751 🏁 Script executed: # Check all usages of db_add_to_deleted_images to see if return value is checked
rg -n "db_add_to_deleted_images" --type=py -B 2 -A 2Repository: AOSSIE-Org/PictoPy Length of output: 936 🏁 Script executed: # Look for similar tombstone/deletion patterns in the codebase
rg -n "deleted_images|tombstone" --type=py -A 3 -B 1Repository: AOSSIE-Org/PictoPy Length of output: 4871 🏁 Script executed: # Get full implementation of db_add_to_deleted_images to confirm return value handling
sed -n '473,491p' backend/app/database/images.pyRepository: AOSSIE-Org/PictoPy Length of output: 552 🏁 Script executed: # Verify the delete_image endpoint doesn't check the return value
sed -n '138,194p' backend/app/routes/images.py | grep -A 30 "Add to tombstone"Repository: AOSSIE-Org/PictoPy Length of output: 1186 Check return value of The function returns db_add_to_deleted_images(original_path) # Return value not checkedIf tombstone insertion fails, deletion proceeds anyway, leaving the image unprotected against re-indexing on the next sync. This violates the tombstone's safety guarantee. Suggested fix: if not db_add_to_deleted_images(original_path):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to mark image as deleted"
)🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| import os | ||
| import pytest | ||
| from fastapi.testclient import TestClient | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| # Add backend to path to allow imports and find main.py | ||
| sys.path.append(str(Path(__file__).parent.parent)) | ||
|
|
||
| from main import app | ||
| from app.database.images import db_get_image_by_id, db_is_image_deleted, db_bulk_insert_images, db_create_images_table | ||
| from app.config.settings import DATABASE_PATH | ||
| import sqlite3 | ||
| import uuid | ||
| import json | ||
|
|
||
| client = TestClient(app) | ||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def setup_db(): | ||
| # Make sure images table (and deleted_images) is created | ||
| db_create_images_table() | ||
|
|
||
| # Use config-defined DATABASE_PATH | ||
| conn = sqlite3.connect(DATABASE_PATH) | ||
| cursor = conn.cursor() | ||
| # Ensure folders table exists (might already be created by conftest) | ||
| cursor.execute(""" | ||
| CREATE TABLE IF NOT EXISTS folders ( | ||
| folder_id TEXT PRIMARY KEY, | ||
| folder_path TEXT UNIQUE | ||
| ) | ||
| """) | ||
| # Insert a fake folder for FK constraints | ||
| cursor.execute("INSERT OR IGNORE INTO folders (folder_id, folder_path) VALUES ('1', '/fake/path')") | ||
| conn.commit() | ||
| conn.close() | ||
|
|
||
| @pytest.fixture | ||
| def mock_image(tmp_path): | ||
| # Setup: Create a fake image file and a fake thumbnail | ||
| img_dir = tmp_path / "images" | ||
| img_dir.mkdir() | ||
| img_path = img_dir / "test_image.jpg" | ||
| img_path.write_bytes(b"fake image data") | ||
|
|
||
| thumb_dir = tmp_path / "thumbnails" | ||
| thumb_dir.mkdir() | ||
| thumb_path = thumb_dir / "test_thumb.jpg" | ||
| thumb_path.write_bytes(b"fake thumb data") | ||
|
|
||
| image_id = str(uuid.uuid4()) | ||
| image_record = { | ||
| "id": image_id, | ||
| "path": str(img_path), | ||
| "folder_id": 1, | ||
| "thumbnailPath": str(thumb_path), | ||
| "metadata": json.dumps({ | ||
| "name": "test_image.jpg", | ||
| "width": 100, | ||
| "height": 100, | ||
| "file_size": 100, | ||
| "file_location": str(img_path), | ||
| "item_type": "image/jpeg" | ||
| }), | ||
| "isTagged": False | ||
| } | ||
|
|
||
| # Insert into DB | ||
| db_bulk_insert_images([image_record]) | ||
|
|
||
| return image_record | ||
|
|
||
| def test_delete_image_success(mock_image): | ||
| image_id = mock_image["id"] | ||
| img_path = mock_image["path"] | ||
| thumb_path = mock_image["thumbnailPath"] | ||
|
|
||
| # 1. Verify existence before deletion | ||
| assert os.path.exists(img_path) | ||
| assert os.path.exists(thumb_path) | ||
| assert db_get_image_by_id(image_id) is not None | ||
|
|
||
| # 2. Call DELETE API | ||
| response = client.delete(f"/images/{image_id}") | ||
| assert response.status_code == 200 | ||
| data = response.json() | ||
| assert data["success"] is True | ||
|
|
||
| # 3. Verify deletion | ||
| assert not os.path.exists(img_path) | ||
| assert not os.path.exists(thumb_path) | ||
| assert db_get_image_by_id(image_id) is None | ||
| assert db_is_image_deleted(img_path) is True | ||
|
|
||
| def test_delete_image_not_found(): | ||
| response = client.delete("/images/non-existent-id") | ||
| assert response.status_code == 404 | ||
|
|
||
| def test_tombstone_prevents_indexing(mock_image): | ||
| image_id = mock_image["id"] | ||
| img_path = mock_image["path"] | ||
|
|
||
| # Delete the image first | ||
| client.delete(f"/images/{image_id}") | ||
| assert db_is_image_deleted(img_path) is True | ||
|
|
||
| # Re-create the file manually | ||
| os.makedirs(os.path.dirname(img_path), exist_ok=True) | ||
| with open(img_path, "wb") as f: | ||
| f.write(b"recreated file") | ||
|
|
||
| # Try to re-index it via utilities | ||
| from app.utils.images import image_util_prepare_image_records | ||
|
|
||
| # Mocking folder_path_to_id | ||
| folder_path = str(Path(img_path).parent) | ||
| records = image_util_prepare_image_records([str(img_path)], {folder_path: 1}) | ||
|
|
||
| # It should skip it because of the tombstone | ||
| assert len(records) == 0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing
conn.rollback()before returningFalseon exception.For consistency with other database functions in this file (e.g.,
db_bulk_insert_images,db_update_image_tagged_status), the exception handler should callconn.rollback()before returningFalse.Proposed fix
except Exception as e: logger.error(f"Error adding to deleted_images: {e}") + conn.rollback() return False finally: conn.close()🤖 Prompt for AI Agents