fix deletes

This commit is contained in:
itqop 2026-01-05 21:11:34 +03:00
parent e741e0aaf0
commit 1a655f317f
2 changed files with 101 additions and 38 deletions

View File

@ -4,7 +4,7 @@ from typing import Optional
from fastapi import APIRouter, Query, status 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 ( from app.api.schemas import (
BreadcrumbsResponse, BreadcrumbsResponse,
FolderCreateRequest, FolderCreateRequest,
@ -133,6 +133,7 @@ async def delete_folder(
folder_id: str, folder_id: str,
current_user: CurrentUser, current_user: CurrentUser,
session: DatabaseSession, session: DatabaseSession,
s3_client: S3ClientDep,
recursive: bool = Query(False), recursive: bool = Query(False),
): ):
""" """
@ -142,9 +143,10 @@ async def delete_folder(
folder_id: Folder ID folder_id: Folder ID
current_user: Current authenticated user current_user: Current authenticated user
session: Database session 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( await folder_service.delete_folder(
user_id=current_user.id, user_id=current_user.id,
folder_id=folder_id, folder_id=folder_id,

View File

@ -3,25 +3,32 @@
from typing import Optional from typing import Optional
from fastapi import HTTPException, status from fastapi import HTTPException, status
from loguru import logger
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.models import Folder from app.domain.models import Folder
from app.infra.s3_client import S3Client
from app.repositories.asset_repository import AssetRepository from app.repositories.asset_repository import AssetRepository
from app.repositories.folder_repository import FolderRepository from app.repositories.folder_repository import FolderRepository
from app.repositories.user_repository import UserRepository
class FolderService: class FolderService:
"""Service for folder management operations (SOLID: Single Responsibility).""" """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. Initialize folder service.
Args: Args:
session: Database session session: Database session
s3_client: Optional S3 client for asset deletion
""" """
self.folder_repo = FolderRepository(session) self.folder_repo = FolderRepository(session)
self.asset_repo = AssetRepository(session) self.asset_repo = AssetRepository(session)
self.user_repo = UserRepository(session)
self.s3_client = s3_client
self.session = session
async def create_folder( async def create_folder(
self, self,
@ -161,33 +168,49 @@ class FolderService:
""" """
Delete a folder. 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: Args:
user_id: User ID user_id: User ID
folder_id: Folder ID folder_id: Folder ID
recursive: If True, delete folder with all subfolders (must still be empty of assets). recursive: If True, recursively delete folder with all subfolders and assets.
If False, fail if folder has subfolders. If False, fail if folder has subfolders or assets.
Raises: 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) folder = await self.get_folder(user_id, folder_id)
# Always check for assets (read-only query, acceptable for validation) if recursive:
asset_count = await self.asset_repo.count_in_folder(folder_id) # Recursive deletion: delete all subfolders and assets
if asset_count > 0: if not self.s3_client:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=( detail="S3 client required for recursive folder deletion",
f"Folder contains {asset_count} assets. " )
"Please delete or move assets first using AssetService endpoints."
), # 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 # Check if folder has subfolders
subfolders = await self.folder_repo.list_by_user( subfolders = await self.folder_repo.list_by_user(
user_id=user_id, 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 # Delete the folder itself
await self.folder_repo.delete(folder) 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]: async def get_breadcrumbs(self, user_id: str, folder_id: str) -> list[Folder]:
""" """