fix deletes
This commit is contained in:
parent
e741e0aaf0
commit
1a655f317f
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue