feat: remove trash

This commit is contained in:
itqop 2025-12-30 22:26:52 +03:00
parent fab5696726
commit c7d68a55a8
12 changed files with 36 additions and 325 deletions

View File

@ -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}

View File

@ -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)

View File

@ -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):

View File

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

View File

@ -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.

View File

@ -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)

View File

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

View File

@ -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>
);

View File

@ -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 = (

View File

@ -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>
);
}

View File

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

View File

@ -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 {