Files
mule-image/backend/app/routers/discard.py
dtoro b870084be0 feat: per-photo permanent delete + discarded thumbnail treatment
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>
2026-04-08 13:12:11 +02:00

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,
}