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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from starlette.responses import FileResponse
|
from starlette.responses import FileResponse
|
||||||
|
|
||||||
@ -60,6 +60,21 @@ async def create_backup(
|
|||||||
return await backup_service.create_backup(user.id)
|
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(
|
@router.delete(
|
||||||
'/{backup_id}/',
|
'/{backup_id}/',
|
||||||
response_model=BackupResponseEntity,
|
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
|
timestamp: datetime.datetime
|
||||||
path: str
|
path: str
|
||||||
filename: str
|
filename: str
|
||||||
|
is_by_user: bool
|
||||||
|
|
||||||
user_id: int
|
user_id: int
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import datetime
|
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
|
from app.domain.models.base import BaseModel
|
||||||
|
|
||||||
@ -11,7 +11,6 @@ class Backup(BaseModel):
|
|||||||
timestamp = Column(DateTime, nullable=False, default=datetime.datetime.now)
|
timestamp = Column(DateTime, nullable=False, default=datetime.datetime.now)
|
||||||
path = Column(String, nullable=False)
|
path = Column(String, nullable=False)
|
||||||
filename = 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)
|
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,20 @@
|
|||||||
|
import io
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
import tarfile
|
import tarfile
|
||||||
import datetime
|
import datetime
|
||||||
from typing import Any, Coroutine, Optional
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from starlette.responses import FileResponse
|
from starlette.responses import FileResponse
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from app.application.backups_repository import BackupsRepository
|
from app.application.backups_repository import BackupsRepository
|
||||||
from app.domain.entities.responses.backup import BackupResponseEntity
|
from app.domain.entities.responses.backup import BackupResponseEntity
|
||||||
from app.domain.models import Backup
|
from app.domain.models import Backup, User
|
||||||
|
|
||||||
|
|
||||||
class BackupService:
|
class BackupService:
|
||||||
@ -80,6 +84,28 @@ class BackupService:
|
|||||||
filename=backup.filename,
|
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]:
|
async def delete_backup(self, backup_id: int) -> Optional[BackupResponseEntity]:
|
||||||
backup = await self.backup_repository.get_by_id(backup_id)
|
backup = await self.backup_repository.get_by_id(backup_id)
|
||||||
|
|
||||||
@ -96,6 +122,24 @@ class BackupService:
|
|||||||
await self.backup_repository.delete(backup)
|
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
|
@staticmethod
|
||||||
def model_to_entity(backup: Backup):
|
def model_to_entity(backup: Backup):
|
||||||
return BackupResponseEntity(
|
return BackupResponseEntity(
|
||||||
@ -103,5 +147,27 @@ class BackupService:
|
|||||||
timestamp=backup.timestamp,
|
timestamp=backup.timestamp,
|
||||||
path=backup.path,
|
path=backup.path,
|
||||||
filename=backup.filename,
|
filename=backup.filename,
|
||||||
|
is_by_user=backup.is_by_user,
|
||||||
user_id=backup.user_id,
|
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'],
|
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,
|
useGetBackupsQuery,
|
||||||
useCreateBackupMutation,
|
useCreateBackupMutation,
|
||||||
useDeleteBackupMutation,
|
useDeleteBackupMutation,
|
||||||
|
useUploadBackupMutation,
|
||||||
} = backupsApi;
|
} = backupsApi;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import {Button, Divider, List, Result, Space, Typography, Upload} from "antd";
|
import {Button, Divider, List, Result, Space, Tooltip, Typography, Upload} from "antd";
|
||||||
import {CloudDownloadOutlined, UploadOutlined} from "@ant-design/icons";
|
import {CloudDownloadOutlined, DeleteOutlined, UploadOutlined} from "@ant-design/icons";
|
||||||
import useBackupManageTab from "./useBackupManageTab.js";
|
import useBackupManageTab from "./useBackupManageTab.js";
|
||||||
import LoadingIndicator from "../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
|
import LoadingIndicator from "../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
|
||||||
|
|
||||||
@ -24,13 +24,16 @@ const BackupManageTab = () => {
|
|||||||
loading={backupManageTabData.isCreatingBackup}
|
loading={backupManageTabData.isCreatingBackup}
|
||||||
|
|
||||||
>
|
>
|
||||||
Создать и скачать резервную копию
|
Создать резервную копию
|
||||||
</Button>
|
</Button>
|
||||||
<Upload
|
<Upload
|
||||||
// beforeUpload={handleUpload}
|
|
||||||
showUploadList={false}
|
showUploadList={false}
|
||||||
accept=".tar.gz,.zip"
|
accept=".tar.gz,.zip"
|
||||||
// disabled={loading}
|
disabled={backupManageTabData.isUploadingBackup}
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
backupManageTabData.uploadBackupHandler(file);
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Button icon={<UploadOutlined/>} block>
|
<Button icon={<UploadOutlined/>} block>
|
||||||
Загрузить резервную копию для восстановления
|
Загрузить резервную копию для восстановления
|
||||||
@ -44,6 +47,7 @@ const BackupManageTab = () => {
|
|||||||
<List.Item>
|
<List.Item>
|
||||||
<Typography.Text>{backup.filename}</Typography.Text>
|
<Typography.Text>{backup.filename}</Typography.Text>
|
||||||
<Typography.Text>Создан: {new Date(backup.timestamp).toLocaleString()}</Typography.Text>
|
<Typography.Text>Создан: {new Date(backup.timestamp).toLocaleString()}</Typography.Text>
|
||||||
|
<Tooltip title="Скачать резервную копию">
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<CloudDownloadOutlined/>}
|
icon={<CloudDownloadOutlined/>}
|
||||||
@ -52,15 +56,18 @@ const BackupManageTab = () => {
|
|||||||
>
|
>
|
||||||
Скачать
|
Скачать
|
||||||
</Button>
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Удалить резервную копию">
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<CloudDownloadOutlined/>}
|
icon={<DeleteOutlined/>}
|
||||||
onClick={() => backupManageTabData.deleteBackupHandler(backup.id)}
|
onClick={() => backupManageTabData.deleteBackupHandler(backup.id)}
|
||||||
loading={backupManageTabData.isDeletingBackup}
|
loading={backupManageTabData.isDeletingBackup}
|
||||||
danger
|
danger
|
||||||
>
|
>
|
||||||
Удалить
|
Удалить
|
||||||
</Button>
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
useCreateBackupMutation,
|
useCreateBackupMutation,
|
||||||
useDeleteBackupMutation,
|
useDeleteBackupMutation,
|
||||||
useGetBackupsQuery
|
useGetBackupsQuery, useUploadBackupMutation
|
||||||
} from "../../../../../Api/backupsApi.js";
|
} from "../../../../../Api/backupsApi.js";
|
||||||
import {useDispatch} from "react-redux";
|
import {useDispatch} from "react-redux";
|
||||||
import {notification} from "antd";
|
import {notification} from "antd";
|
||||||
@ -16,6 +16,7 @@ const useBackupManageTab = () => {
|
|||||||
});
|
});
|
||||||
const [createBackup, {isLoading: isCreatingBackup}] = useCreateBackupMutation();
|
const [createBackup, {isLoading: isCreatingBackup}] = useCreateBackupMutation();
|
||||||
const [deleteBackup, {isLoading: isDeletingBackup}] = useDeleteBackupMutation();
|
const [deleteBackup, {isLoading: isDeletingBackup}] = useDeleteBackupMutation();
|
||||||
|
const [uploadBackup, {isLoading: isUploadingBackup}] = useUploadBackupMutation();
|
||||||
|
|
||||||
const [isDownloadingBackup, setDownloadingFiles] = useState(false);
|
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 {
|
return {
|
||||||
backups,
|
backups,
|
||||||
isLoadingBackups,
|
isLoadingBackups,
|
||||||
@ -107,9 +133,11 @@ const useBackupManageTab = () => {
|
|||||||
isCreatingBackup,
|
isCreatingBackup,
|
||||||
isDownloadingBackup,
|
isDownloadingBackup,
|
||||||
isDeletingBackup,
|
isDeletingBackup,
|
||||||
|
isUploadingBackup,
|
||||||
createBackupHandler,
|
createBackupHandler,
|
||||||
downloadBackupHandler,
|
downloadBackupHandler,
|
||||||
deleteBackupHandler,
|
deleteBackupHandler,
|
||||||
|
uploadBackupHandler,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user