feat: Implement bulk delete functionality for photos in API and frontend
This commit introduces a new feature for bulk deleting photos, allowing admins to permanently remove multiple photos at once. The backend has been updated with a new API endpoint for handling bulk delete requests, including response handling for missing photo IDs. The frontend has been enhanced with a confirmation dialog and a button to trigger the bulk delete action, improving the user experience. Documentation has been updated to reflect these changes.
This commit is contained in:
parent
f9e8c476bc
commit
a0cc3a985a
@ -31,6 +31,12 @@ export interface UploadResponse {
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface BulkDeletePhotosResponse {
|
||||
message: string
|
||||
deleted_count: number
|
||||
missing_photo_ids: number[]
|
||||
}
|
||||
|
||||
export const photosApi = {
|
||||
importPhotos: async (
|
||||
request: PhotoImportRequest
|
||||
@ -119,6 +125,14 @@ export const photosApi = {
|
||||
return data
|
||||
},
|
||||
|
||||
bulkDeletePhotos: async (photoIds: number[]): Promise<BulkDeletePhotosResponse> => {
|
||||
const { data } = await apiClient.post<BulkDeletePhotosResponse>(
|
||||
'/api/v1/photos/bulk-delete',
|
||||
{ photo_ids: photoIds }
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
openFolder: async (photoId: number): Promise<{ message: string; folder: string }> => {
|
||||
const { data } = await apiClient.post<{ message: string; folder: string }>(
|
||||
`/api/v1/photos/${photoId}/open-folder`
|
||||
|
||||
@ -59,6 +59,7 @@ export default function Search() {
|
||||
const [loadingTags, setLoadingTags] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [loadingFavorites, setLoadingFavorites] = useState(false)
|
||||
const [deletingPhotos, setDeletingPhotos] = useState(false)
|
||||
|
||||
// Photo viewer
|
||||
const [showPhotoViewer, setShowPhotoViewer] = useState(false)
|
||||
@ -454,6 +455,41 @@ export default function Search() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSelectedPhotos = async () => {
|
||||
if (selectedPhotos.size === 0) {
|
||||
alert('Please select photos to delete.')
|
||||
return
|
||||
}
|
||||
|
||||
const warningLines = [
|
||||
`⚠️ WARNING: Permanently delete ${selectedPhotos.size} photo(s)?`,
|
||||
'',
|
||||
]
|
||||
|
||||
warningLines.push('This action CANNOT be undone. Continue?')
|
||||
|
||||
if (!confirm(warningLines.join('\n'))) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeletingPhotos(true)
|
||||
try {
|
||||
const response = await photosApi.bulkDeletePhotos(Array.from(selectedPhotos))
|
||||
const missingNote = response.missing_photo_ids.length > 0
|
||||
? `\nMissing photo IDs: ${response.missing_photo_ids.join(', ')}`
|
||||
: ''
|
||||
alert(`${response.message}${missingNote}`)
|
||||
await performSearch(page)
|
||||
setSelectedPhotos(new Set())
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting photos:', error)
|
||||
const errorMessage = error?.response?.data?.detail || error?.message || 'Failed to delete photos.'
|
||||
alert(`Error deleting photos: ${errorMessage}`)
|
||||
} finally {
|
||||
setDeletingPhotos(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleFavorite = async (photoId: number) => {
|
||||
try {
|
||||
await photosApi.toggleFavorite(photoId)
|
||||
@ -747,6 +783,14 @@ export default function Search() {
|
||||
>
|
||||
Tag selected photos
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteSelectedPhotos}
|
||||
disabled={selectedPhotos.size === 0 || deletingPhotos}
|
||||
className="px-3 py-1 text-sm border rounded text-red-700 border-red-200 hover:bg-red-50 disabled:bg-gray-100 disabled:text-gray-400 disabled:border-gray-200"
|
||||
title="Permanently delete selected photos"
|
||||
>
|
||||
{deletingPhotos ? 'Deleting...' : '🗑 Delete selected'}
|
||||
</button>
|
||||
<button
|
||||
onClick={selectAll}
|
||||
disabled={results.length === 0}
|
||||
|
||||
@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from src.web.db.session import get_db
|
||||
from src.web.api.auth import get_current_user
|
||||
from src.web.api.users import get_current_admin_user
|
||||
|
||||
# Redis connection for RQ
|
||||
redis_conn = Redis(host="localhost", port=6379, db=0, decode_responses=False)
|
||||
@ -24,6 +25,8 @@ from src.web.schemas.photos import (
|
||||
PhotoResponse,
|
||||
BulkAddFavoritesRequest,
|
||||
BulkAddFavoritesResponse,
|
||||
BulkDeletePhotosRequest,
|
||||
BulkDeletePhotosResponse,
|
||||
BulkRemoveFavoritesRequest,
|
||||
BulkRemoveFavoritesResponse,
|
||||
)
|
||||
@ -728,6 +731,60 @@ def bulk_remove_favorites(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bulk-delete", response_model=BulkDeletePhotosResponse)
|
||||
def bulk_delete_photos(
|
||||
request: BulkDeletePhotosRequest,
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> BulkDeletePhotosResponse:
|
||||
"""Delete multiple photos and all related data (faces, encodings, tags, favorites)."""
|
||||
from src.web.db.models import Photo, PhotoTagLinkage
|
||||
|
||||
photo_ids = list(dict.fromkeys(request.photo_ids))
|
||||
if not photo_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="photo_ids list cannot be empty",
|
||||
)
|
||||
|
||||
try:
|
||||
photos = db.query(Photo).filter(Photo.id.in_(photo_ids)).all()
|
||||
found_ids = {photo.id for photo in photos}
|
||||
missing_ids = sorted(set(photo_ids) - found_ids)
|
||||
|
||||
deleted_count = 0
|
||||
for photo in photos:
|
||||
# Remove tag linkages explicitly (in addition to cascade) to keep counts accurate
|
||||
db.query(PhotoTagLinkage).filter(
|
||||
PhotoTagLinkage.photo_id == photo.id
|
||||
).delete(synchronize_session=False)
|
||||
db.delete(photo)
|
||||
deleted_count += 1
|
||||
|
||||
db.commit()
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - safety net
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete photos: {exc}",
|
||||
)
|
||||
|
||||
admin_username = current_admin.get("username", "unknown")
|
||||
message_parts = [f"Deleted {deleted_count} photo(s)"]
|
||||
if missing_ids:
|
||||
message_parts.append(f"{len(missing_ids)} photo(s) not found")
|
||||
message_parts.append(f"Request by admin: {admin_username}")
|
||||
|
||||
return BulkDeletePhotosResponse(
|
||||
message="; ".join(message_parts),
|
||||
deleted_count=deleted_count,
|
||||
missing_photo_ids=missing_ids,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{photo_id}/open-folder")
|
||||
def open_photo_folder(photo_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
"""Open the folder containing the photo in the system file manager and select the file.
|
||||
|
||||
@ -74,3 +74,20 @@ class BulkRemoveFavoritesResponse(BaseModel):
|
||||
not_favorite_count: int
|
||||
total_requested: int
|
||||
|
||||
|
||||
class BulkDeletePhotosRequest(BaseModel):
|
||||
"""Request to delete multiple photos permanently."""
|
||||
|
||||
photo_ids: List[int] = Field(..., description="List of photo IDs to delete")
|
||||
|
||||
|
||||
class BulkDeletePhotosResponse(BaseModel):
|
||||
"""Response for bulk delete photos operation."""
|
||||
|
||||
message: str
|
||||
deleted_count: int
|
||||
missing_photo_ids: List[int] = Field(
|
||||
default_factory=list,
|
||||
description="Photo IDs that were requested but not found",
|
||||
)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user