feat: Добавлена загрузка резервных копий

This commit is contained in:
Андрей Дувакин 2025-07-02 10:19:35 +05:00
parent 039d7cb0cb
commit c70b109851
8 changed files with 192 additions and 29 deletions

View File

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

View File

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

View File

@ -8,5 +8,6 @@ class BackupResponseEntity(BaseModel):
timestamp: datetime.datetime
path: str
filename: str
is_by_user: bool
user_id: int

View File

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

View File

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

View File

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

View File

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

View File

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