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

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 timestamp: datetime.datetime
path: str path: str
filename: str filename: str
is_by_user: bool
user_id: int user_id: int

View File

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

View File

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

View File

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

View File

@ -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,23 +47,27 @@ 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>
<Button <Tooltip title="Скачать резервную копию">
type="primary" <Button
icon={<CloudDownloadOutlined/>} type="primary"
onClick={() => backupManageTabData.downloadBackupHandler(backup.id, backup.filename)} icon={<CloudDownloadOutlined/>}
loading={backupManageTabData.isDownloadingBackup} onClick={() => backupManageTabData.downloadBackupHandler(backup.id, backup.filename)}
> loading={backupManageTabData.isDownloadingBackup}
Скачать >
</Button> Скачать
<Button </Button>
type="primary" </Tooltip>
icon={<CloudDownloadOutlined/>} <Tooltip title="Удалить резервную копию">
onClick={() => backupManageTabData.deleteBackupHandler(backup.id)} <Button
loading={backupManageTabData.isDeletingBackup} type="primary"
danger icon={<DeleteOutlined/>}
> onClick={() => backupManageTabData.deleteBackupHandler(backup.id)}
Удалить loading={backupManageTabData.isDeletingBackup}
</Button> danger
>
Удалить
</Button>
</Tooltip>
</List.Item> </List.Item>
)} )}
/> />

View File

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