""" 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 from app.models.user import User from app.dependencies import get_current_user from app.services.nextcloud_dav import delete_for_user, is_nextcloud_path logger = logging.getLogger(__name__) router = APIRouter() @router.get("") async def list_discarded(db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """List discarded photos""" result = await db.execute( select(Photo).where(Photo.is_discarded == True, Photo.user_id == current_user.id) ) photos = result.scalars().all() return photos @router.post("/restore") async def restore_photos(photo_ids: list[str], db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)): """Restore photos from the discard pile""" result = await db.execute( select(Photo).where(and_(Photo.id.in_(photo_ids), Photo.is_discarded == True, Photo.user_id == current_user.id)) ) 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), current_user: User = Depends(get_current_user)): """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, Photo.user_id == current_user.id) ) photos = result.scalars().all() return await _permanently_delete(db, photos, current_user) @router.delete("") async def delete_discarded( photo_ids: list[str] = Body(..., embed=True), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """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, Photo.user_id == current_user.id) ) ) photos = result.scalars().all() return await _permanently_delete(db, photos, current_user) async def _permanently_delete(db: AsyncSession, photos: list[Photo], user: User) -> dict: """Shared helper: unlink files for the given photos and delete their rows. Per-file errors are counted but don't abort the batch. For files inside a Nextcloud-rooted SourceRoot the unlink is dispatched through Nextcloud's WebDAV `DELETE` so Nextcloud moves the file into the user's trashbin and updates `oc_filecache`. For everything else we fall back to plain `os.unlink`. """ deleted = 0 file_errors = 0 for photo in photos: try: if photo.filepath: if is_nextcloud_path(photo.filepath): # WebDAV DELETE — Nextcloud moves to trashbin and # updates oc_filecache. The bind mount will then # reflect the file's absence (Nextcloud writes # synchronously). 404 from NC is treated as already # gone (idempotent). delete_for_user(user, photo.filepath) elif os.path.exists(photo.filepath): os.unlink(photo.filepath) except HTTPException as e: # WebDAV-side error — surface to caller via the file_errors # counter rather than aborting the whole batch. file_errors += 1 logger.error(f"Failed to delete {photo.filepath} via Nextcloud: {e.detail}") continue 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, }