feat: Добавлена загрузка резервных копий
This commit is contained in:
parent
039d7cb0cb
commit
c70b109851
@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, File, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
@ -60,6 +60,21 @@ async def create_backup(
|
||||
return await backup_service.create_backup(user.id)
|
||||
|
||||
|
||||
@router.post(
|
||||
'/upload/',
|
||||
response_model=BackupResponseEntity,
|
||||
summary='Upload a backup',
|
||||
description='Upload a backup',
|
||||
)
|
||||
async def upload_backup(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user=Depends(require_admin),
|
||||
):
|
||||
backup_service = BackupService(db)
|
||||
return await backup_service.upload_backup(file, user)
|
||||
|
||||
|
||||
@router.delete(
|
||||
'/{backup_id}/',
|
||||
response_model=BackupResponseEntity,
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
"""0007 добавил в backups поле is_by_user
|
||||
|
||||
Revision ID: 4f3877d7a2b1
|
||||
Revises: b58238896c0f
|
||||
Create Date: 2025-07-01 18:54:27.796866
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '4f3877d7a2b1'
|
||||
down_revision: Union[str, None] = 'b58238896c0f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('backups', sa.Column('is_by_user', sa.Boolean(), nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('backups', 'is_by_user')
|
||||
# ### end Alembic commands ###
|
||||
@ -8,5 +8,6 @@ class BackupResponseEntity(BaseModel):
|
||||
timestamp: datetime.datetime
|
||||
path: str
|
||||
filename: str
|
||||
is_by_user: bool
|
||||
|
||||
user_id: int
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import datetime
|
||||
|
||||
from sqlalchemy import Column, DateTime, String, Integer, ForeignKey
|
||||
from sqlalchemy import Column, DateTime, String, Integer, ForeignKey, Boolean
|
||||
|
||||
from app.domain.models.base import BaseModel
|
||||
|
||||
@ -11,7 +11,6 @@ class Backup(BaseModel):
|
||||
timestamp = Column(DateTime, nullable=False, default=datetime.datetime.now)
|
||||
path = Column(String, nullable=False)
|
||||
filename = Column(String, nullable=False)
|
||||
is_by_user = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
|
||||
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
import io
|
||||
import subprocess
|
||||
import os
|
||||
import tarfile
|
||||
import datetime
|
||||
from typing import Any, Coroutine, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
import aiofiles
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from magic import magic
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.responses import FileResponse
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from app.application.backups_repository import BackupsRepository
|
||||
from app.domain.entities.responses.backup import BackupResponseEntity
|
||||
from app.domain.models import Backup
|
||||
from app.domain.models import Backup, User
|
||||
|
||||
|
||||
class BackupService:
|
||||
@ -80,6 +84,28 @@ class BackupService:
|
||||
filename=backup.filename,
|
||||
)
|
||||
|
||||
async def upload_backup(self, file: UploadFile, user: User) -> BackupResponseEntity:
|
||||
file_bytes = await file.read()
|
||||
file.file.seek(0)
|
||||
self.validate_file_type(file)
|
||||
|
||||
if not self.validate_backup_archive(file_bytes,
|
||||
expected_app_files_dir_name=os.path.basename(self.app_files_dir)):
|
||||
raise HTTPException(400, "Неверная структура архива резервной копии")
|
||||
|
||||
filename = self.generate_filename(file)
|
||||
backup_path = os.path.join(self.backup_dir, filename)
|
||||
async with aiofiles.open(backup_path, 'wb') as out_file:
|
||||
await out_file.write(file_bytes)
|
||||
|
||||
backup_record = Backup(
|
||||
filename=filename,
|
||||
path=backup_path,
|
||||
user_id=user.id,
|
||||
)
|
||||
await self.backup_repository.create(backup_record)
|
||||
return self.model_to_entity(backup_record)
|
||||
|
||||
async def delete_backup(self, backup_id: int) -> Optional[BackupResponseEntity]:
|
||||
backup = await self.backup_repository.get_by_id(backup_id)
|
||||
|
||||
@ -96,6 +122,24 @@ class BackupService:
|
||||
await self.backup_repository.delete(backup)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def generate_filename(file: UploadFile) -> str:
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return secure_filename(f"uploaded_{timestamp}_{file.filename}")
|
||||
|
||||
@staticmethod
|
||||
def validate_backup_archive(file_bytes: bytes, expected_app_files_dir_name: str) -> bool:
|
||||
try:
|
||||
with tarfile.open(fileobj=io.BytesIO(file_bytes), mode="r:gz") as tar:
|
||||
members = tar.getnames()
|
||||
if "db_dump.sql" not in members:
|
||||
return False
|
||||
if not any(name.startswith(expected_app_files_dir_name) for name in members):
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def model_to_entity(backup: Backup):
|
||||
return BackupResponseEntity(
|
||||
@ -103,5 +147,27 @@ class BackupService:
|
||||
timestamp=backup.timestamp,
|
||||
path=backup.path,
|
||||
filename=backup.filename,
|
||||
is_by_user=backup.is_by_user,
|
||||
user_id=backup.user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def validate_file_type(file: UploadFile):
|
||||
mime = magic.Magic(mime=True)
|
||||
file_type = mime.from_buffer(file.file.read(1024))
|
||||
file.file.seek(0)
|
||||
content = file.file.read()
|
||||
print(content.decode('utf-8'))
|
||||
if file_type not in ["application/zip", "application/gzip"]:
|
||||
raise HTTPException(400, "Неправильный формат файла")
|
||||
|
||||
@staticmethod
|
||||
def get_media_type(filename: str) -> str:
|
||||
extension = filename.split('.')[-1].lower()
|
||||
if extension in ['zip']:
|
||||
return "application/zip"
|
||||
|
||||
if extension == 'tar.gz':
|
||||
return "application/gzip"
|
||||
|
||||
return "application/octet-stream"
|
||||
|
||||
@ -25,6 +25,22 @@ export const backupsApi = createApi({
|
||||
}),
|
||||
invalidatesTags: ['Backup'],
|
||||
}),
|
||||
uploadBackup: builder.mutation({
|
||||
query: (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
return {
|
||||
url: '/backups/upload/',
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
formData: true,
|
||||
body: formData,
|
||||
};
|
||||
},
|
||||
invalidatesTags: ['Backup'],
|
||||
}),
|
||||
|
||||
}),
|
||||
});
|
||||
|
||||
@ -32,4 +48,5 @@ export const {
|
||||
useGetBackupsQuery,
|
||||
useCreateBackupMutation,
|
||||
useDeleteBackupMutation,
|
||||
useUploadBackupMutation,
|
||||
} = backupsApi;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {Button, Divider, List, Result, Space, Typography, Upload} from "antd";
|
||||
import {CloudDownloadOutlined, UploadOutlined} from "@ant-design/icons";
|
||||
import {Button, Divider, List, Result, Space, Tooltip, Typography, Upload} from "antd";
|
||||
import {CloudDownloadOutlined, DeleteOutlined, UploadOutlined} from "@ant-design/icons";
|
||||
import useBackupManageTab from "./useBackupManageTab.js";
|
||||
import LoadingIndicator from "../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
|
||||
|
||||
@ -24,13 +24,16 @@ const BackupManageTab = () => {
|
||||
loading={backupManageTabData.isCreatingBackup}
|
||||
|
||||
>
|
||||
Создать и скачать резервную копию
|
||||
Создать резервную копию
|
||||
</Button>
|
||||
<Upload
|
||||
// beforeUpload={handleUpload}
|
||||
showUploadList={false}
|
||||
accept=".tar.gz,.zip"
|
||||
// disabled={loading}
|
||||
disabled={backupManageTabData.isUploadingBackup}
|
||||
beforeUpload={(file) => {
|
||||
backupManageTabData.uploadBackupHandler(file);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Button icon={<UploadOutlined/>} block>
|
||||
Загрузить резервную копию для восстановления
|
||||
@ -44,23 +47,27 @@ const BackupManageTab = () => {
|
||||
<List.Item>
|
||||
<Typography.Text>{backup.filename}</Typography.Text>
|
||||
<Typography.Text>Создан: {new Date(backup.timestamp).toLocaleString()}</Typography.Text>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudDownloadOutlined/>}
|
||||
onClick={() => backupManageTabData.downloadBackupHandler(backup.id, backup.filename)}
|
||||
loading={backupManageTabData.isDownloadingBackup}
|
||||
>
|
||||
Скачать
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudDownloadOutlined/>}
|
||||
onClick={() => backupManageTabData.deleteBackupHandler(backup.id)}
|
||||
loading={backupManageTabData.isDeletingBackup}
|
||||
danger
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
<Tooltip title="Скачать резервную копию">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudDownloadOutlined/>}
|
||||
onClick={() => backupManageTabData.downloadBackupHandler(backup.id, backup.filename)}
|
||||
loading={backupManageTabData.isDownloadingBackup}
|
||||
>
|
||||
Скачать
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Удалить резервную копию">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DeleteOutlined/>}
|
||||
onClick={() => backupManageTabData.deleteBackupHandler(backup.id)}
|
||||
loading={backupManageTabData.isDeletingBackup}
|
||||
danger
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {
|
||||
useCreateBackupMutation,
|
||||
useDeleteBackupMutation,
|
||||
useGetBackupsQuery
|
||||
useGetBackupsQuery, useUploadBackupMutation
|
||||
} from "../../../../../Api/backupsApi.js";
|
||||
import {useDispatch} from "react-redux";
|
||||
import {notification} from "antd";
|
||||
@ -16,6 +16,7 @@ const useBackupManageTab = () => {
|
||||
});
|
||||
const [createBackup, {isLoading: isCreatingBackup}] = useCreateBackupMutation();
|
||||
const [deleteBackup, {isLoading: isDeletingBackup}] = useDeleteBackupMutation();
|
||||
const [uploadBackup, {isLoading: isUploadingBackup}] = useUploadBackupMutation();
|
||||
|
||||
const [isDownloadingBackup, setDownloadingFiles] = useState(false);
|
||||
|
||||
@ -100,6 +101,31 @@ const useBackupManageTab = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const uploadBackupHandler = async (file) => {
|
||||
try {
|
||||
if (!file || !(file instanceof File)) {
|
||||
notification.error({
|
||||
message: "Ошибка",
|
||||
description: "Файл не выбран",
|
||||
placement: "topRight",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await uploadBackup(file).unwrap();
|
||||
notification.success({
|
||||
message: "Успех",
|
||||
description: "Резервная копия успешно загружена",
|
||||
placement: "topRight",
|
||||
});
|
||||
} catch (e) {
|
||||
notification.error({
|
||||
message: "Ошибка",
|
||||
description: e.message || e?.data?.detail || "Не удалось загрузить резервную копию",
|
||||
placement: "topRight",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
backups,
|
||||
isLoadingBackups,
|
||||
@ -107,9 +133,11 @@ const useBackupManageTab = () => {
|
||||
isCreatingBackup,
|
||||
isDownloadingBackup,
|
||||
isDeletingBackup,
|
||||
isUploadingBackup,
|
||||
createBackupHandler,
|
||||
downloadBackupHandler,
|
||||
deleteBackupHandler,
|
||||
uploadBackupHandler,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user