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

View File

@ -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]:
"""