diff --git a/backend/src/app/api/schemas.py b/backend/src/app/api/schemas.py index 5ffc73f..d0cae50 100644 --- a/backend/src/app/api/schemas.py +++ b/backend/src/app/api/schemas.py @@ -61,7 +61,6 @@ class AssetResponse(BaseModel): storage_key_original: str storage_key_thumb: Optional[str] = None created_at: datetime - deleted_at: Optional[datetime] = None model_config = {"from_attributes": True} diff --git a/backend/src/app/api/v1/assets.py b/backend/src/app/api/v1/assets.py index 914093e..d5a4e1f 100644 --- a/backend/src/app/api/v1/assets.py +++ b/backend/src/app/api/v1/assets.py @@ -145,7 +145,7 @@ async def stream_media( ) -@router.delete("/{asset_id}", response_model=AssetResponse) +@router.delete("/{asset_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_asset( asset_id: str, current_user: CurrentUser, @@ -153,55 +153,7 @@ async def delete_asset( s3_client: S3ClientDep, ): """ - Soft delete an asset (move to trash). - - Args: - asset_id: Asset ID - current_user: Current authenticated user - session: Database session - s3_client: S3 client - - Returns: - Updated asset - """ - asset_service = AssetService(session, s3_client) - asset = await asset_service.delete_asset(user_id=current_user.id, asset_id=asset_id) - return asset - - -@router.post("/{asset_id}/restore", response_model=AssetResponse) -async def restore_asset( - asset_id: str, - current_user: CurrentUser, - session: DatabaseSession, - s3_client: S3ClientDep, -): - """ - Restore a soft-deleted asset. - - Args: - asset_id: Asset ID - current_user: Current authenticated user - session: Database session - s3_client: S3 client - - Returns: - Updated asset - """ - asset_service = AssetService(session, s3_client) - asset = await asset_service.restore_asset(user_id=current_user.id, asset_id=asset_id) - return asset - - -@router.delete("/{asset_id}/purge", status_code=status.HTTP_204_NO_CONTENT) -async def purge_asset( - asset_id: str, - current_user: CurrentUser, - session: DatabaseSession, - s3_client: S3ClientDep, -): - """ - Permanently delete an asset. + Delete an asset permanently (move to trash bucket, delete from DB). Args: asset_id: Asset ID @@ -210,4 +162,4 @@ async def purge_asset( s3_client: S3 client """ asset_service = AssetService(session, s3_client) - await asset_service.purge_asset(user_id=current_user.id, asset_id=asset_id) + await asset_service.delete_asset(user_id=current_user.id, asset_id=asset_id) diff --git a/backend/src/app/domain/models.py b/backend/src/app/domain/models.py index d39bf68..9e3f8d6 100644 --- a/backend/src/app/domain/models.py +++ b/backend/src/app/domain/models.py @@ -29,7 +29,6 @@ class AssetStatus(str, enum.Enum): UPLOADING = "uploading" READY = "ready" FAILED = "failed" - DELETED = "deleted" class User(Base): @@ -79,9 +78,6 @@ class Asset(Base): created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False, index=True ) - deleted_at: Mapped[Optional[datetime]] = mapped_column( - DateTime(timezone=True), nullable=True, index=True - ) class Share(Base): diff --git a/backend/src/app/infra/s3_client.py b/backend/src/app/infra/s3_client.py index 456f172..40b5dbd 100644 --- a/backend/src/app/infra/s3_client.py +++ b/backend/src/app/infra/s3_client.py @@ -142,6 +142,27 @@ class S3Client: except ClientError: pass + def move_to_trash(self, storage_key: str) -> None: + """ + Move an object from media bucket to trash bucket. + + Args: + storage_key: S3 object key in media bucket + """ + trash_bucket = "itcloud-trash" + try: + # Copy object to trash bucket + self.client.copy_object( + Bucket=trash_bucket, + Key=storage_key, + CopySource={"Bucket": self.bucket, "Key": storage_key}, + ) + # Delete from media bucket + self.client.delete_object(Bucket=self.bucket, Key=storage_key) + except ClientError as e: + # If object doesn't exist, ignore + pass + async def stream_object( self, storage_key: str, chunk_size: int = 8192 ) -> Tuple[AsyncIterator[bytes], int]: diff --git a/backend/src/app/repositories/asset_repository.py b/backend/src/app/repositories/asset_repository.py index 8ed237f..c44d5f8 100644 --- a/backend/src/app/repositories/asset_repository.py +++ b/backend/src/app/repositories/asset_repository.py @@ -77,7 +77,6 @@ class AssetRepository: limit: int = 50, cursor: Optional[str] = None, asset_type: Optional[AssetType] = None, - include_deleted: bool = False, ) -> list[Asset]: """ List assets for a user. @@ -87,16 +86,12 @@ class AssetRepository: limit: Maximum number of results cursor: Pagination cursor (asset_id) asset_type: Filter by asset type - include_deleted: Include soft-deleted assets Returns: List of assets """ query = select(Asset).where(Asset.user_id == user_id) - if not include_deleted: - query = query.where(Asset.deleted_at.is_(None)) - if asset_type: query = query.where(Asset.type == asset_type) @@ -124,34 +119,6 @@ class AssetRepository: await self.session.refresh(asset) return asset - async def soft_delete(self, asset: Asset) -> Asset: - """ - Soft delete an asset. - - Args: - asset: Asset to delete - - Returns: - Updated asset - """ - asset.deleted_at = datetime.now(timezone.utc) - asset.status = AssetStatus.DELETED - return await self.update(asset) - - async def restore(self, asset: Asset) -> Asset: - """ - Restore a soft-deleted asset. - - Args: - asset: Asset to restore - - Returns: - Updated asset - """ - asset.deleted_at = None - asset.status = AssetStatus.READY - return await self.update(asset) - async def delete(self, asset: Asset) -> None: """ Permanently delete an asset. diff --git a/backend/src/app/services/asset_service.py b/backend/src/app/services/asset_service.py index f55c942..ff2be5e 100644 --- a/backend/src/app/services/asset_service.py +++ b/backend/src/app/services/asset_service.py @@ -315,71 +315,20 @@ class AssetService: return file_stream, content_type, content_length - async def delete_asset(self, user_id: str, asset_id: str) -> Asset: + async def delete_asset(self, user_id: str, asset_id: str) -> None: """ - Soft delete an asset. + Delete an asset permanently (move files to trash bucket, delete from DB). Args: user_id: User ID asset_id: Asset ID - - Returns: - Updated asset """ asset = await self.get_asset(user_id, asset_id) - return await self.asset_repo.soft_delete(asset) - async def restore_asset(self, user_id: str, asset_id: str) -> Asset: - """ - Restore a soft-deleted asset. - - Args: - user_id: User ID - asset_id: Asset ID - - Returns: - Updated asset - """ - asset = await self.asset_repo.get_by_id(asset_id) - if not asset or asset.user_id != user_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Asset not found", - ) - - if not asset.deleted_at: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Asset is not deleted", - ) - - return await self.asset_repo.restore(asset) - - async def purge_asset(self, user_id: str, asset_id: str) -> None: - """ - Permanently delete an asset. - - Args: - user_id: User ID - asset_id: Asset ID - """ - asset = await self.asset_repo.get_by_id(asset_id) - if not asset or asset.user_id != user_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Asset not found", - ) - - if not asset.deleted_at: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Asset must be deleted before purging", - ) - - # Delete from S3 - self.s3_client.delete_object(asset.storage_key_original) + # Move files to trash bucket in S3 + self.s3_client.move_to_trash(asset.storage_key_original) if asset.storage_key_thumb: - self.s3_client.delete_object(asset.storage_key_thumb) + self.s3_client.move_to_trash(asset.storage_key_thumb) # Delete from database await self.asset_repo.delete(asset) diff --git a/docker-compose.yml b/docker-compose.yml index 061689c..c58f8d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,13 +92,16 @@ services: - MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin} - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin} - MEDIA_BUCKET=itcloud-media + - TRASH_BUCKET=itcloud-trash entrypoint: > /bin/sh -c " set -e; mc alias set myminio http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD; mc mb myminio/$$MEDIA_BUCKET --ignore-existing; + mc mb myminio/$$TRASH_BUCKET --ignore-existing; mc anonymous set none myminio/$$MEDIA_BUCKET; - echo 'MinIO bucket ensured:' $$MEDIA_BUCKET; + mc anonymous set none myminio/$$TRASH_BUCKET; + echo 'MinIO buckets ensured:' $$MEDIA_BUCKET $$TRASH_BUCKET; " restart: "no" networks: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8fcbca2..c7db03d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,6 @@ import { Box } from '@mui/material'; import LoginPage from './pages/LoginPage'; import RegisterPage from './pages/RegisterPage'; import LibraryPage from './pages/LibraryPage'; -import TrashPage from './pages/TrashPage'; import ShareViewPage from './pages/ShareViewPage'; import { useAuth } from './hooks/useAuth'; @@ -31,14 +30,6 @@ function App() { } /> - - - - } - /> } /> ); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index bb0a978..042b15a 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -19,7 +19,6 @@ import { Menu as MenuIcon, CloudUpload as CloudIcon, PhotoLibrary as LibraryIcon, - Delete as TrashIcon, Logout as LogoutIcon, } from '@mui/icons-material'; import { useAuth } from '../hooks/useAuth'; @@ -56,7 +55,6 @@ export default function Layout({ children }: LayoutProps) { const menuItems = [ { text: 'Библиотека', icon: , path: '/library' }, - { text: 'Корзина', icon: , path: '/trash' }, ]; const drawer = ( diff --git a/frontend/src/pages/TrashPage.tsx b/frontend/src/pages/TrashPage.tsx deleted file mode 100644 index 6fae661..0000000 --- a/frontend/src/pages/TrashPage.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { useState, useEffect } from 'react'; -import { - Box, - Grid, - Typography, - CircularProgress, - Button, - Snackbar, -} from '@mui/material'; -import Layout from '../components/Layout'; -import MediaCard from '../components/MediaCard'; -import type { Asset } from '../types'; -import api from '../services/api'; - -export default function TrashPage() { - const [assets, setAssets] = useState([]); - const [loading, setLoading] = useState(true); - const [selectedAssets, setSelectedAssets] = useState>(new Set()); - const [snackbarOpen, setSnackbarOpen] = useState(false); - const [snackbarMessage, setSnackbarMessage] = useState(''); - - useEffect(() => { - loadDeletedAssets(); - }, []); - - const loadDeletedAssets = async () => { - try { - setLoading(true); - // We need to get all assets and filter deleted ones - // TODO: Add deleted filter to API - const response = await api.listAssets({ limit: 200 }); - const deletedAssets = response.items.filter((asset) => asset.deleted_at); - setAssets(deletedAssets); - } catch (error) { - console.error('Failed to load deleted assets:', error); - } finally { - setLoading(false); - } - }; - - const handleSelect = (assetId: string, selected: boolean) => { - const newSelected = new Set(selectedAssets); - if (selected) { - newSelected.add(assetId); - } else { - newSelected.delete(assetId); - } - setSelectedAssets(newSelected); - }; - - const handleRestore = async () => { - if (selectedAssets.size === 0) return; - - try { - await Promise.all( - Array.from(selectedAssets).map((assetId) => api.restoreAsset(assetId)) - ); - setAssets(assets.filter((a) => !selectedAssets.has(a.id))); - setSelectedAssets(new Set()); - showSnackbar('Файлы восстановлены'); - } catch (error) { - console.error('Failed to restore assets:', error); - showSnackbar('Ошибка при восстановлении файлов'); - } - }; - - const handlePurge = async () => { - if (selectedAssets.size === 0) return; - - if (!confirm('Вы уверены? Файлы будут удалены навсегда.')) { - return; - } - - try { - await Promise.all( - Array.from(selectedAssets).map((assetId) => api.purgeAsset(assetId)) - ); - setAssets(assets.filter((a) => !selectedAssets.has(a.id))); - setSelectedAssets(new Set()); - showSnackbar('Файлы удалены навсегда'); - } catch (error) { - console.error('Failed to purge assets:', error); - showSnackbar('Ошибка при удалении файлов'); - } - }; - - const showSnackbar = (message: string) => { - setSnackbarMessage(message); - setSnackbarOpen(true); - }; - - return ( - - - {/* Actions */} - {selectedAssets.size > 0 && ( - - - Выбрано: {selectedAssets.size} - - - - - )} - - {/* Content */} - - {loading && ( - - - - )} - - {!loading && assets.length === 0 && ( - - - Корзина пуста - - - Удаленные файлы будут отображаться здесь - - - )} - - {assets.length > 0 && ( - - {assets.map((asset) => ( - - - - ))} - - )} - - - {/* Snackbar */} - setSnackbarOpen(false)} - message={snackbarMessage} - /> - - - ); -} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 88f3449..1b82237 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -102,18 +102,8 @@ class ApiClient { return response.data; } - async deleteAsset(assetId: string): Promise { - const { data } = await this.client.delete(`/assets/${assetId}`); - return data; - } - - async restoreAsset(assetId: string): Promise { - const { data } = await this.client.post(`/assets/${assetId}/restore`); - return data; - } - - async purgeAsset(assetId: string): Promise { - await this.client.delete(`/assets/${assetId}/purge`); + async deleteAsset(assetId: string): Promise { + await this.client.delete(`/assets/${assetId}`); } // Upload diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 62b412b..3dd147b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -12,7 +12,7 @@ export interface AuthTokens { } export type AssetType = 'photo' | 'video'; -export type AssetStatus = 'uploading' | 'ready' | 'failed' | 'deleted'; +export type AssetStatus = 'uploading' | 'ready' | 'failed'; export interface Asset { id: string; @@ -30,7 +30,6 @@ export interface Asset { storage_key_original: string; storage_key_thumb?: string; created_at: string; - deleted_at?: string; } export interface AssetListResponse {