diff --git a/backend/src/app/api/v1/folders.py b/backend/src/app/api/v1/folders.py index 93758f0..e5dc720 100644 --- a/backend/src/app/api/v1/folders.py +++ b/backend/src/app/api/v1/folders.py @@ -4,7 +4,7 @@ from typing import Optional from fastapi import APIRouter, Query, status -from app.api.dependencies import CurrentUser, DatabaseSession +from app.api.dependencies import CurrentUser, DatabaseSession, S3ClientDep from app.api.schemas import ( BreadcrumbsResponse, FolderCreateRequest, @@ -133,6 +133,7 @@ async def delete_folder( folder_id: str, current_user: CurrentUser, session: DatabaseSession, + s3_client: S3ClientDep, recursive: bool = Query(False), ): """ @@ -142,9 +143,10 @@ async def delete_folder( folder_id: Folder ID current_user: Current authenticated user session: Database session - recursive: If True, delete folder with all contents + s3_client: S3 client (required for recursive deletion with assets) + recursive: If True, recursively delete folder with all subfolders and assets """ - folder_service = FolderService(session) + folder_service = FolderService(session, s3_client) await folder_service.delete_folder( user_id=current_user.id, folder_id=folder_id, diff --git a/backend/src/app/services/folder_service.py b/backend/src/app/services/folder_service.py index 9e68f80..0bfebe6 100644 --- a/backend/src/app/services/folder_service.py +++ b/backend/src/app/services/folder_service.py @@ -3,25 +3,32 @@ from typing import Optional from fastapi import HTTPException, status +from loguru import logger from sqlalchemy.ext.asyncio import AsyncSession from app.domain.models import Folder +from app.infra.s3_client import S3Client from app.repositories.asset_repository import AssetRepository from app.repositories.folder_repository import FolderRepository +from app.repositories.user_repository import UserRepository class FolderService: """Service for folder management operations (SOLID: Single Responsibility).""" - def __init__(self, session: AsyncSession): + def __init__(self, session: AsyncSession, s3_client: Optional[S3Client] = None): """ Initialize folder service. Args: session: Database session + s3_client: Optional S3 client for asset deletion """ self.folder_repo = FolderRepository(session) self.asset_repo = AssetRepository(session) + self.user_repo = UserRepository(session) + self.s3_client = s3_client + self.session = session async def create_folder( self, @@ -161,33 +168,49 @@ class FolderService: """ Delete a folder. - IMPORTANT: Folder must be empty (no assets, no subfolders) to be deleted. - Use AssetService or BatchOperationsService to delete assets first, - or move them to another folder before deleting. - Args: user_id: User ID folder_id: Folder ID - recursive: If True, delete folder with all subfolders (must still be empty of assets). - If False, fail if folder has subfolders. + recursive: If True, recursively delete folder with all subfolders and assets. + If False, fail if folder has subfolders or assets. Raises: - HTTPException: If folder not found, not authorized, or not empty + HTTPException: If folder not found, not authorized, or not empty (when recursive=False) """ folder = await self.get_folder(user_id, folder_id) - # Always check for assets (read-only query, acceptable for validation) - asset_count = await self.asset_repo.count_in_folder(folder_id) - if asset_count > 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=( - f"Folder contains {asset_count} assets. " - "Please delete or move assets first using AssetService endpoints." - ), - ) + if recursive: + # Recursive deletion: delete all subfolders and assets + if not self.s3_client: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="S3 client required for recursive folder deletion", + ) + + # Get all subfolders (deepest first) + subfolders = await self.folder_repo.get_all_subfolders(folder_id) + + # Delete assets in all subfolders (deepest first) + for subfolder in reversed(subfolders): + await self._delete_folder_assets(user_id, subfolder.id) + await self.folder_repo.delete(subfolder) + logger.info(f"Deleted subfolder {subfolder.id} ({subfolder.name})") + + # Delete assets in the folder itself + await self._delete_folder_assets(user_id, folder_id) + + else: + # Non-recursive: folder must be empty + asset_count = await self.asset_repo.count_in_folder(folder_id) + if asset_count > 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Folder contains {asset_count} assets. " + "Use recursive=true to delete folder with contents, or delete/move assets first." + ), + ) - if not recursive: # Check if folder has subfolders subfolders = await self.folder_repo.list_by_user( user_id=user_id, @@ -202,24 +225,62 @@ class FolderService: ), ) - if recursive: - # Delete all subfolders recursively (folders must be empty of assets) - subfolders = await self.folder_repo.get_all_subfolders(folder_id) - for subfolder in reversed(subfolders): # Delete from deepest to shallowest - # Check that subfolder is empty of assets - subfolder_asset_count = await self.asset_repo.count_in_folder(subfolder.id) - if subfolder_asset_count > 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=( - f"Subfolder '{subfolder.name}' contains {subfolder_asset_count} assets. " - "All folders must be empty before deletion." - ), - ) - await self.folder_repo.delete(subfolder) - # Delete the folder itself await self.folder_repo.delete(folder) + logger.info(f"Deleted folder {folder_id} ({folder.name})") + + async def _delete_folder_assets(self, user_id: str, folder_id: str) -> None: + """ + Delete all assets in a folder. + + Moves files to trash bucket in S3, deletes from DB, and updates storage quota. + + Args: + user_id: User ID (for ownership verification) + folder_id: Folder ID + """ + # Get all assets in folder + assets = await self.asset_repo.list_by_folder( + user_id=user_id, + folder_id=folder_id, + limit=10000, # Large limit to get all assets + ) + + if not assets: + return + + total_bytes_freed = 0 + + # Delete each asset + for asset in assets: + try: + # Move files to trash bucket + self.s3_client.move_to_trash(asset.storage_key_original) + if asset.storage_key_thumb: + self.s3_client.move_to_trash(asset.storage_key_thumb) + + # Track bytes for storage quota update + total_bytes_freed += asset.size_bytes + + # Delete from database + await self.asset_repo.delete(asset) + + logger.debug(f"Deleted asset {asset.id} from folder {folder_id}") + + except Exception as e: + logger.error(f"Failed to delete asset {asset.id} from folder {folder_id}: {e}") + # Continue with other assets + + # Update user's storage_used_bytes (free up quota) + if total_bytes_freed > 0: + user = await self.user_repo.get_by_id(user_id) + if user: + user.storage_used_bytes = max(0, user.storage_used_bytes - total_bytes_freed) + await self.user_repo.update(user) + logger.info( + f"Freed {total_bytes_freed} bytes for user {user_id} " + f"(deleted {len(assets)} assets from folder {folder_id})" + ) async def get_breadcrumbs(self, user_id: str, folder_id: str) -> list[Folder]: """