Compare commits
5 Commits
2447bc53af
...
b52c0d16fa
| Author | SHA1 | Date | |
|---|---|---|---|
| b52c0d16fa | |||
| c70b109851 | |||
| 039d7cb0cb | |||
| 22c1a9ca80 | |||
| 1a12d389fc |
32
api/app/application/backups_repository.py
Normal file
32
api/app/application/backups_repository.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from typing import Sequence, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.models import Backup
|
||||||
|
|
||||||
|
|
||||||
|
class BackupsRepository:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_all(self) -> Sequence[Backup]:
|
||||||
|
stmt = select(Backup)
|
||||||
|
result = await self.db.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def get_by_id(self, backup_id: int) -> Optional[Backup]:
|
||||||
|
stmt = select(Backup).filter_by(id=backup_id)
|
||||||
|
result = await self.db.execute(stmt)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
async def create(self, backup: Backup) -> Backup:
|
||||||
|
self.db.add(backup)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(backup)
|
||||||
|
return backup
|
||||||
|
|
||||||
|
async def delete(self, backup: Backup) -> Backup:
|
||||||
|
await self.db.delete(backup)
|
||||||
|
await self.db.commit()
|
||||||
|
return backup
|
||||||
@ -1,6 +1,9 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, File, UploadFile
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from starlette.responses import FileResponse
|
from starlette.responses import FileResponse
|
||||||
|
|
||||||
|
from app.database.session import get_db
|
||||||
|
from app.domain.entities.responses.backup import BackupResponseEntity
|
||||||
from app.infrastructure.backup_service import BackupService
|
from app.infrastructure.backup_service import BackupService
|
||||||
from app.infrastructure.dependencies import require_admin
|
from app.infrastructure.dependencies import require_admin
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
@ -8,19 +11,90 @@ from app.settings import settings
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
'/',
|
||||||
|
response_model=list[BackupResponseEntity],
|
||||||
|
summary='Get all backups',
|
||||||
|
description='Get all backups',
|
||||||
|
)
|
||||||
|
async def get_all_backups(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user=Depends(require_admin),
|
||||||
|
):
|
||||||
|
backup_service = BackupService(db)
|
||||||
|
return await backup_service.get_all_backups()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
'/{backup_id}/',
|
||||||
|
response_model=BackupResponseEntity,
|
||||||
|
summary='Get a backup file',
|
||||||
|
description='Get a backup file',
|
||||||
|
)
|
||||||
|
async def get_backup(
|
||||||
|
backup_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user=Depends(require_admin),
|
||||||
|
):
|
||||||
|
backup_service = BackupService(db)
|
||||||
|
return await backup_service.get_backup_file_by_id(backup_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/create/",
|
"/",
|
||||||
response_class=FileResponse,
|
response_model=BackupResponseEntity,
|
||||||
summary="Create backup",
|
summary="Create backup",
|
||||||
description="Create backup",
|
description="Create backup",
|
||||||
)
|
)
|
||||||
async def create_backup(
|
async def create_backup(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
user=Depends(require_admin),
|
user=Depends(require_admin),
|
||||||
):
|
):
|
||||||
backup_service = BackupService(
|
backup_service = BackupService(
|
||||||
db_url=settings.BACKUP_DB_URL,
|
db,
|
||||||
app_files_dir=settings.FILE_UPLOAD_DIR,
|
settings.BACKUP_DB_URL,
|
||||||
backup_dir=settings.BACKUP_DIR,
|
settings.FILE_UPLOAD_DIR,
|
||||||
pg_dump_path=settings.PG_DUMP_PATH,
|
settings.BACKUP_DIR,
|
||||||
|
settings.PG_DUMP_PATH,
|
||||||
)
|
)
|
||||||
return await backup_service.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,
|
||||||
|
backup_dir=settings.BACKUP_DIR,
|
||||||
|
app_files_dir=settings.FILE_UPLOAD_DIR,
|
||||||
|
)
|
||||||
|
return await backup_service.upload_backup(file, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
'/{backup_id}/',
|
||||||
|
response_model=BackupResponseEntity,
|
||||||
|
summary='Delete backup',
|
||||||
|
description='Delete backup',
|
||||||
|
)
|
||||||
|
async def delete_backup(
|
||||||
|
backup_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user=Depends(require_admin),
|
||||||
|
):
|
||||||
|
backup_service = BackupService(
|
||||||
|
db,
|
||||||
|
settings.BACKUP_DB_URL,
|
||||||
|
settings.FILE_UPLOAD_DIR,
|
||||||
|
settings.BACKUP_DIR,
|
||||||
|
settings.PG_DUMP_PATH,
|
||||||
|
)
|
||||||
|
return await backup_service.delete_backup(backup_id)
|
||||||
|
|||||||
@ -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 ###
|
||||||
13
api/app/domain/entities/responses/backup.py
Normal file
13
api/app/domain/entities/responses/backup.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class BackupResponseEntity(BaseModel):
|
||||||
|
id: int
|
||||||
|
timestamp: datetime.datetime
|
||||||
|
path: str
|
||||||
|
filename: str
|
||||||
|
is_by_user: bool
|
||||||
|
|
||||||
|
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,40 +1,174 @@
|
|||||||
|
import io
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
import tarfile
|
import tarfile
|
||||||
import datetime
|
import datetime
|
||||||
from fastapi import HTTPException
|
from typing import Any, Coroutine, Optional
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
|
from fastapi import HTTPException, UploadFile
|
||||||
|
from magic import magic
|
||||||
|
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.domain.entities.responses.backup import BackupResponseEntity
|
||||||
|
from app.domain.models import Backup, User
|
||||||
|
|
||||||
|
|
||||||
class BackupService:
|
class BackupService:
|
||||||
def __init__(self, db_url: str, app_files_dir: str, backup_dir: str, pg_dump_path: str):
|
def __init__(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
db_url: str = None,
|
||||||
|
app_files_dir: str = None,
|
||||||
|
backup_dir: str = None,
|
||||||
|
pg_dump_path: str = None
|
||||||
|
):
|
||||||
|
self.backup_repository = BackupsRepository(db)
|
||||||
self.db_url = db_url
|
self.db_url = db_url
|
||||||
self.app_files_dir = app_files_dir
|
self.app_files_dir = app_files_dir
|
||||||
self.backup_dir = backup_dir
|
self.backup_dir = backup_dir
|
||||||
self.pg_dump_path = pg_dump_path
|
self.pg_dump_path = pg_dump_path
|
||||||
|
|
||||||
|
if backup_dir:
|
||||||
os.makedirs(backup_dir, exist_ok=True)
|
os.makedirs(backup_dir, exist_ok=True)
|
||||||
|
|
||||||
async def create_backup(self) -> FileResponse:
|
async def get_all_backups(self) -> list[BackupResponseEntity]:
|
||||||
|
backups = await self.backup_repository.get_all()
|
||||||
|
return [
|
||||||
|
self.model_to_entity(backup)
|
||||||
|
for backup in backups
|
||||||
|
]
|
||||||
|
|
||||||
|
async def create_backup(self, user_id: int) -> BackupResponseEntity:
|
||||||
try:
|
try:
|
||||||
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
backup_name = f"backup_{timestamp}"
|
backup_name = f"backup_{timestamp}.tar.gz"
|
||||||
backup_path = os.path.join(self.backup_dir, backup_name)
|
backup_path = os.path.join(self.backup_dir, backup_name)
|
||||||
|
|
||||||
db_dump_path = f"{os.getcwd()}/{backup_path}.sql"
|
db_dump_path = f"{os.getcwd()}/{backup_path}.sql"
|
||||||
dump_cmd = f'"{self.pg_dump_path}" -Fc -d {self.db_url} -f "{db_dump_path}"'
|
dump_cmd = f'"{self.pg_dump_path}" -Fc -d {self.db_url} -f "{db_dump_path}"'
|
||||||
subprocess.run(dump_cmd, shell=True, check=True)
|
subprocess.run(dump_cmd, shell=True, check=True)
|
||||||
|
|
||||||
with tarfile.open(f"{backup_path}.tar.gz", "w:gz") as tar:
|
with tarfile.open(backup_path, "w:gz") as tar:
|
||||||
tar.add(self.app_files_dir, arcname=self.app_files_dir)
|
tar.add(self.app_files_dir, arcname=os.path.basename(self.app_files_dir))
|
||||||
tar.add(db_dump_path, arcname="db_dump.sql")
|
tar.add(db_dump_path, arcname="db_dump.sql")
|
||||||
|
|
||||||
os.remove(db_dump_path)
|
backup_record = Backup(
|
||||||
return FileResponse(
|
|
||||||
f"{backup_path}.tar.gz",
|
|
||||||
media_type="application/gzip",
|
|
||||||
filename=backup_name,
|
filename=backup_name,
|
||||||
|
path=backup_path,
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
await self.backup_repository.create(backup_record)
|
||||||
|
|
||||||
|
os.remove(db_dump_path)
|
||||||
|
return self.model_to_entity(backup_record)
|
||||||
|
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
print(e)
|
|
||||||
raise HTTPException(500, f"Ошибка создания бэкапа: {e}")
|
raise HTTPException(500, f"Ошибка создания бэкапа: {e}")
|
||||||
|
|
||||||
|
async def get_backup_file_by_id(self, backup_id: int) -> FileResponse:
|
||||||
|
backup = await self.backup_repository.get_by_id(backup_id)
|
||||||
|
|
||||||
|
if not backup:
|
||||||
|
raise HTTPException(404, 'Резервная копия с таким id не найдена')
|
||||||
|
|
||||||
|
if not os.path.exists(backup.path):
|
||||||
|
raise HTTPException(404, 'Файл не найден на диске')
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
backup.path,
|
||||||
|
media_type="application/gzip",
|
||||||
|
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, 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,
|
||||||
|
is_by_user=True,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
if not backup:
|
||||||
|
raise HTTPException(404, 'Резервная копия с таким id не найдена')
|
||||||
|
|
||||||
|
if not os.path.exists(backup.path):
|
||||||
|
raise HTTPException(404, 'Файл не найден на диске')
|
||||||
|
|
||||||
|
if os.path.exists(backup.path):
|
||||||
|
os.remove(backup.path)
|
||||||
|
|
||||||
|
return self.model_to_entity(
|
||||||
|
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 as e:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def model_to_entity(backup: Backup):
|
||||||
|
return BackupResponseEntity(
|
||||||
|
id=backup.id,
|
||||||
|
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)
|
||||||
|
if file_type not in ["application/zip", "application/gzip", "application/x-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"
|
||||||
|
|||||||
52
web-app/src/Api/backupsApi.js
Normal file
52
web-app/src/Api/backupsApi.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import {createApi} from "@reduxjs/toolkit/query/react";
|
||||||
|
import {baseQueryWithAuth} from "./baseQuery.js";
|
||||||
|
|
||||||
|
|
||||||
|
export const backupsApi = createApi({
|
||||||
|
reducerPath: 'backupsApi',
|
||||||
|
baseQuery: baseQueryWithAuth,
|
||||||
|
tagTypes: ['Backup'],
|
||||||
|
endpoints: (builder) => ({
|
||||||
|
getBackups: builder.query({
|
||||||
|
query: () => `/backups/`,
|
||||||
|
providesTags: ['Backup'],
|
||||||
|
}),
|
||||||
|
createBackup: builder.mutation({
|
||||||
|
query: () => ({
|
||||||
|
url: '/backups/',
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
invalidatesTags: ['Backup'],
|
||||||
|
}),
|
||||||
|
deleteBackup: builder.mutation({
|
||||||
|
query: (backupId) => ({
|
||||||
|
url: `/backups/${backupId}/`,
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
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'],
|
||||||
|
}),
|
||||||
|
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
useGetBackupsQuery,
|
||||||
|
useCreateBackupMutation,
|
||||||
|
useDeleteBackupMutation,
|
||||||
|
useUploadBackupMutation,
|
||||||
|
} = backupsApi;
|
||||||
@ -9,8 +9,7 @@ export const baseQuery = fetchBaseQuery({
|
|||||||
if (token) {
|
if (token) {
|
||||||
headers.set('Authorization', `Bearer ${token}`);
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
}
|
}
|
||||||
|
if (endpoint === 'uploadAppointmentFile' || endpoint === 'uploadBackup') {
|
||||||
if (endpoint === 'uploadAppointmentFile') {
|
|
||||||
const mutation = getState()?.api?.mutations?.[Object.keys(getState()?.api?.mutations || {})[0]];
|
const mutation = getState()?.api?.mutations?.[Object.keys(getState()?.api?.mutations || {})[0]];
|
||||||
if (mutation?.body instanceof FormData) {
|
if (mutation?.body instanceof FormData) {
|
||||||
headers.delete('Content-Type');
|
headers.delete('Content-Type');
|
||||||
|
|||||||
@ -202,7 +202,7 @@ const AppointmentFormModal = () => {
|
|||||||
fileList={appointmentFormModalUI.draftFiles}
|
fileList={appointmentFormModalUI.draftFiles}
|
||||||
beforeUpload={(file) => {
|
beforeUpload={(file) => {
|
||||||
appointmentFormModalUI.handleAddFile(file);
|
appointmentFormModalUI.handleAddFile(file);
|
||||||
return false; // Prevent auto-upload
|
return false;
|
||||||
}}
|
}}
|
||||||
onRemove={(file) => appointmentFormModalUI.handleRemoveFile(file)}
|
onRemove={(file) => appointmentFormModalUI.handleRemoveFile(file)}
|
||||||
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"
|
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"
|
||||||
|
|||||||
@ -1,37 +1,78 @@
|
|||||||
import {Button, Space, Spin, 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 LoadingIndicator from "../../../../Widgets/LoadingIndicator/LoadingIndicator.jsx";
|
||||||
|
|
||||||
const BackupManageTab = () => {
|
const BackupManageTab = () => {
|
||||||
|
const backupManageTabData = useBackupManageTab();
|
||||||
|
|
||||||
|
if (backupManageTabData.isLoadingBackups) {
|
||||||
|
return <LoadingIndicator/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupManageTabData.isErrorBackups) {
|
||||||
|
return <Result status={500} title="Произошла ошибка при загрузке резервных копий"/>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Spin spinning={false}>
|
<>
|
||||||
<Typography>
|
<Space direction="horizontal" size="large" style={{width: "100%"}}>
|
||||||
<Typography.Title level={4}>Управление резервными копиями</Typography.Title>
|
|
||||||
<Typography.Paragraph>
|
|
||||||
Здесь вы можете создать резервную копию системы и восстановить её из архива.
|
|
||||||
</Typography.Paragraph>
|
|
||||||
</Typography>
|
|
||||||
<Space direction="vertical" size="large" style={{width: "100%"}}>
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<CloudDownloadOutlined/>}
|
icon={<CloudDownloadOutlined/>}
|
||||||
// onClick={handleCreateBackup}
|
onClick={backupManageTabData.createBackupHandler}
|
||||||
// disabled={loading}
|
loading={backupManageTabData.isCreatingBackup}
|
||||||
block
|
|
||||||
>
|
>
|
||||||
Создать и скачать бэкап
|
Создать резервную копию
|
||||||
</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>
|
||||||
Загрузить бэкап для восстановления
|
Загрузить резервную копию для восстановления
|
||||||
</Button>
|
</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
</Space>
|
</Space>
|
||||||
</Spin>
|
<Divider/>
|
||||||
|
<List
|
||||||
|
dataSource={backupManageTabData.backups}
|
||||||
|
renderItem={(backup) => (
|
||||||
|
<List.Item>
|
||||||
|
<Typography.Text>{backup.filename}</Typography.Text>
|
||||||
|
<Typography.Text>{backup.is_by_user ? "Загружена" : "Создана"}: {new Date(backup.timestamp).toLocaleString()}</Typography.Text>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,144 @@
|
|||||||
|
import {
|
||||||
|
useCreateBackupMutation,
|
||||||
|
useDeleteBackupMutation,
|
||||||
|
useGetBackupsQuery, useUploadBackupMutation
|
||||||
|
} from "../../../../../Api/backupsApi.js";
|
||||||
|
import {useDispatch} from "react-redux";
|
||||||
|
import {notification} from "antd";
|
||||||
|
import {baseQueryWithAuth} from "../../../../../Api/baseQuery.js";
|
||||||
|
import {useState} from "react";
|
||||||
|
|
||||||
|
|
||||||
|
const useBackupManageTab = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const {data: backups, isLoading: isLoadingBackups, isError: isErrorBackups} = useGetBackupsQuery(undefined, {
|
||||||
|
pollingInterval: 60000,
|
||||||
|
});
|
||||||
|
const [createBackup, {isLoading: isCreatingBackup}] = useCreateBackupMutation();
|
||||||
|
const [deleteBackup, {isLoading: isDeletingBackup}] = useDeleteBackupMutation();
|
||||||
|
const [uploadBackup, {isLoading: isUploadingBackup}] = useUploadBackupMutation();
|
||||||
|
|
||||||
|
const [isDownloadingBackup, setDownloadingFiles] = useState(false);
|
||||||
|
|
||||||
|
const createBackupHandler = async () => {
|
||||||
|
try {
|
||||||
|
await createBackup().unwrap();
|
||||||
|
notification.success({
|
||||||
|
message: "Успех",
|
||||||
|
description: "Резервная копия успешно создана",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
notification.error({
|
||||||
|
message: "Ошибка",
|
||||||
|
description: e?.data?.detail || "Не удалось создать резервную копию",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadBackupHandler = async (backupId, fileName) => {
|
||||||
|
try {
|
||||||
|
setDownloadingFiles(true);
|
||||||
|
const {url, ...options} = await baseQueryWithAuth(
|
||||||
|
{
|
||||||
|
url: `/backups/${backupId}/`,
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
notification.error({
|
||||||
|
message: "Ошибка при скачивании файла",
|
||||||
|
description: "Не удалось загрузить файл.",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const downloadUrl = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = downloadUrl;
|
||||||
|
link.setAttribute("download", fileName || "file");
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(downloadUrl);
|
||||||
|
setDownloadingFiles(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
notification.error({
|
||||||
|
message: "Ошибка",
|
||||||
|
description: e?.data?.detail || "Не удалось загрузить резервную копию",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteBackupHandler = async (backupId) => {
|
||||||
|
try {
|
||||||
|
await deleteBackup(backupId).unwrap();
|
||||||
|
notification.success({
|
||||||
|
message: "Успех",
|
||||||
|
description: "Резервная копия успешно удалена",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
notification.error({
|
||||||
|
message: "Ошибка",
|
||||||
|
description: e?.data?.detail || "Не удалось удалить резервную копию",
|
||||||
|
placement: "topRight",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
isErrorBackups,
|
||||||
|
isCreatingBackup,
|
||||||
|
isDownloadingBackup,
|
||||||
|
isDeletingBackup,
|
||||||
|
isUploadingBackup,
|
||||||
|
createBackupHandler,
|
||||||
|
downloadBackupHandler,
|
||||||
|
deleteBackupHandler,
|
||||||
|
uploadBackupHandler,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useBackupManageTab;
|
||||||
@ -21,6 +21,7 @@ import {rolesApi} from "../Api/rolesApi.js";
|
|||||||
import adminReducer from "./Slices/adminSlice.js";
|
import adminReducer from "./Slices/adminSlice.js";
|
||||||
import {registerApi} from "../Api/registerApi.js";
|
import {registerApi} from "../Api/registerApi.js";
|
||||||
import {appointmentFilesApi} from "../Api/appointmentFilesApi.js";
|
import {appointmentFilesApi} from "../Api/appointmentFilesApi.js";
|
||||||
|
import {backupsApi} from "../Api/backupsApi.js";
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
@ -60,6 +61,8 @@ export const store = configureStore({
|
|||||||
[registerApi.reducerPath]: registerApi.reducer,
|
[registerApi.reducerPath]: registerApi.reducer,
|
||||||
|
|
||||||
[appointmentFilesApi.reducerPath]: appointmentFilesApi.reducer,
|
[appointmentFilesApi.reducerPath]: appointmentFilesApi.reducer,
|
||||||
|
|
||||||
|
[backupsApi.reducerPath]: backupsApi.reducer
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) => (
|
middleware: (getDefaultMiddleware) => (
|
||||||
getDefaultMiddleware().concat(
|
getDefaultMiddleware().concat(
|
||||||
@ -77,6 +80,7 @@ export const store = configureStore({
|
|||||||
rolesApi.middleware,
|
rolesApi.middleware,
|
||||||
registerApi.middleware,
|
registerApi.middleware,
|
||||||
appointmentFilesApi.middleware,
|
appointmentFilesApi.middleware,
|
||||||
|
backupsApi.middleware,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user