feat: remove trash
This commit is contained in:
parent
fab5696726
commit
c7d68a55a8
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/trash"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<TrashPage />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/library" />} />
|
||||
</Routes>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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: <LibraryIcon />, path: '/library' },
|
||||
{ text: 'Корзина', icon: <TrashIcon />, path: '/trash' },
|
||||
];
|
||||
|
||||
const drawer = (
|
||||
|
|
|
|||
|
|
@ -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<Asset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedAssets, setSelectedAssets] = useState<Set<string>>(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 (
|
||||
<Layout>
|
||||
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Actions */}
|
||||
{selectedAssets.size > 0 && (
|
||||
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider', display: 'flex', gap: 2 }}>
|
||||
<Typography variant="body1" sx={{ flexGrow: 1, alignSelf: 'center' }}>
|
||||
Выбрано: {selectedAssets.size}
|
||||
</Typography>
|
||||
<Button variant="outlined" onClick={handleRestore}>
|
||||
Восстановить
|
||||
</Button>
|
||||
<Button variant="outlined" color="error" onClick={handlePurge}>
|
||||
Удалить навсегда
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{ flexGrow: 1, overflow: 'auto', p: 2 }}>
|
||||
{loading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && assets.length === 0 && (
|
||||
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
Корзина пуста
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Удаленные файлы будут отображаться здесь
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{assets.length > 0 && (
|
||||
<Grid container spacing={2}>
|
||||
{assets.map((asset) => (
|
||||
<Grid item xs={6} sm={4} md={3} lg={2} key={asset.id}>
|
||||
<MediaCard
|
||||
asset={asset}
|
||||
selected={selectedAssets.has(asset.id)}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Snackbar */}
|
||||
<Snackbar
|
||||
open={snackbarOpen}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackbarOpen(false)}
|
||||
message={snackbarMessage}
|
||||
/>
|
||||
</Box>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
@ -102,18 +102,8 @@ class ApiClient {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
async deleteAsset(assetId: string): Promise<Asset> {
|
||||
const { data } = await this.client.delete(`/assets/${assetId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async restoreAsset(assetId: string): Promise<Asset> {
|
||||
const { data } = await this.client.post(`/assets/${assetId}/restore`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async purgeAsset(assetId: string): Promise<void> {
|
||||
await this.client.delete(`/assets/${assetId}/purge`);
|
||||
async deleteAsset(assetId: string): Promise<void> {
|
||||
await this.client.delete(`/assets/${assetId}`);
|
||||
}
|
||||
|
||||
// Upload
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue