Discarded photos now look discarded in the grid (50% opacity + grayscale)
with a red trash badge in the corner instead of a bare icon. The discard
action bar gains a "Delete N" button that permanently deletes only the
current selection, complementing the existing "Empty discard pile".
Backend: new DELETE /discard endpoint accepting {photo_ids: [...]} that
permanently removes only listed photos. Skips ids that aren't in the
discard pile so it can never bypass the soft-delete safety net.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
95 lines
2.8 KiB
Python
95 lines
2.8 KiB
Python
"""
|
|
Discard API router
|
|
"""
|
|
import os
|
|
import logging
|
|
from fastapi import APIRouter, Depends, HTTPException, Body
|
|
from sqlalchemy import select, and_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.models import Photo
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
@router.get("")
|
|
async def list_discarded(db: AsyncSession = Depends(get_db)):
|
|
"""List discarded photos"""
|
|
result = await db.execute(
|
|
select(Photo).where(Photo.is_discarded == True)
|
|
)
|
|
photos = result.scalars().all()
|
|
return photos
|
|
|
|
@router.post("/restore")
|
|
async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db)):
|
|
"""Restore photos from the discard pile"""
|
|
result = await db.execute(
|
|
select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True))
|
|
)
|
|
photos = result.scalars().all()
|
|
|
|
for photo in photos:
|
|
photo.is_discarded = False
|
|
photo.discarded_at = None
|
|
|
|
await db.commit()
|
|
return {"status": "success", "restored": len(photos)}
|
|
|
|
@router.delete("/empty")
|
|
async def empty_discard(db: AsyncSession = Depends(get_db)):
|
|
"""Permanently delete all discarded photos and unlink their files from
|
|
disk. Failures on individual files are logged but don't abort the batch.
|
|
"""
|
|
result = await db.execute(
|
|
select(Photo).where(Photo.is_discarded == True)
|
|
)
|
|
photos = result.scalars().all()
|
|
return await _permanently_delete(db, photos)
|
|
|
|
|
|
@router.delete("")
|
|
async def delete_discarded(
|
|
photo_ids: list[str] = Body(..., embed=True),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Permanently delete a specific subset of discarded photos. The photos
|
|
must already be in the discard pile — non-discarded ids are skipped so
|
|
this can never bypass the soft-delete safety net.
|
|
"""
|
|
if not photo_ids:
|
|
return {"status": "success", "deleted": 0, "file_errors": 0}
|
|
result = await db.execute(
|
|
select(Photo).where(
|
|
and_(Photo.id.in_(photo_ids), Photo.is_discarded == True)
|
|
)
|
|
)
|
|
photos = result.scalars().all()
|
|
return await _permanently_delete(db, photos)
|
|
|
|
|
|
async def _permanently_delete(db: AsyncSession, photos: list[Photo]) -> dict:
|
|
"""Shared helper: unlink files for the given photos and delete their
|
|
rows. Per-file errors are counted but don't abort the batch.
|
|
"""
|
|
deleted = 0
|
|
file_errors = 0
|
|
for photo in photos:
|
|
try:
|
|
if photo.filepath and os.path.exists(photo.filepath):
|
|
os.unlink(photo.filepath)
|
|
except OSError as e:
|
|
file_errors += 1
|
|
logger.error(f"Failed to unlink {photo.filepath}: {e}")
|
|
await db.delete(photo)
|
|
deleted += 1
|
|
|
|
await db.commit()
|
|
return {
|
|
"status": "success",
|
|
"deleted": deleted,
|
|
"file_errors": file_errors,
|
|
}
|