Compare commits

...

5 Commits

12 changed files with 566 additions and 44 deletions

View 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

View File

@ -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 app.database.session import get_db
from app.domain.entities.responses.backup import BackupResponseEntity
from app.infrastructure.backup_service import BackupService
from app.infrastructure.dependencies import require_admin
from app.settings import settings
@ -8,19 +11,90 @@ from app.settings import settings
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(
"/create/",
response_class=FileResponse,
"/",
response_model=BackupResponseEntity,
summary="Create backup",
description="Create backup",
)
async def create_backup(
db: AsyncSession = Depends(get_db),
user=Depends(require_admin),
):
backup_service = BackupService(
db_url=settings.BACKUP_DB_URL,
app_files_dir=settings.FILE_UPLOAD_DIR,
backup_dir=settings.BACKUP_DIR,
pg_dump_path=settings.PG_DUMP_PATH,
db,
settings.BACKUP_DB_URL,
settings.FILE_UPLOAD_DIR,
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)

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

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

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,40 +1,174 @@
import io
import subprocess
import os
import tarfile
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 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:
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.app_files_dir = app_files_dir
self.backup_dir = backup_dir
self.pg_dump_path = pg_dump_path
os.makedirs(backup_dir, exist_ok=True)
async def create_backup(self) -> FileResponse:
if backup_dir:
os.makedirs(backup_dir, exist_ok=True)
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:
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)
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}"'
subprocess.run(dump_cmd, shell=True, check=True)
with tarfile.open(f"{backup_path}.tar.gz", "w:gz") as tar:
tar.add(self.app_files_dir, arcname=self.app_files_dir)
with tarfile.open(backup_path, "w:gz") as tar:
tar.add(self.app_files_dir, arcname=os.path.basename(self.app_files_dir))
tar.add(db_dump_path, arcname="db_dump.sql")
os.remove(db_dump_path)
return FileResponse(
f"{backup_path}.tar.gz",
media_type="application/gzip",
backup_record = Backup(
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:
print(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"

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

View File

@ -9,8 +9,7 @@ export const baseQuery = fetchBaseQuery({
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
if (endpoint === 'uploadAppointmentFile') {
if (endpoint === 'uploadAppointmentFile' || endpoint === 'uploadBackup') {
const mutation = getState()?.api?.mutations?.[Object.keys(getState()?.api?.mutations || {})[0]];
if (mutation?.body instanceof FormData) {
headers.delete('Content-Type');

View File

@ -202,7 +202,7 @@ const AppointmentFormModal = () => {
fileList={appointmentFormModalUI.draftFiles}
beforeUpload={(file) => {
appointmentFormModalUI.handleAddFile(file);
return false; // Prevent auto-upload
return false;
}}
onRemove={(file) => appointmentFormModalUI.handleRemoveFile(file)}
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png"

View File

@ -1,37 +1,78 @@
import {Button, Space, Spin, 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";
const BackupManageTab = () => {
const backupManageTabData = useBackupManageTab();
if (backupManageTabData.isLoadingBackups) {
return <LoadingIndicator/>;
}
if (backupManageTabData.isErrorBackups) {
return <Result status={500} title="Произошла ошибка при загрузке резервных копий"/>
}
return (
<Spin spinning={false}>
<Typography>
<Typography.Title level={4}>Управление резервными копиями</Typography.Title>
<Typography.Paragraph>
Здесь вы можете создать резервную копию системы и восстановить её из архива.
</Typography.Paragraph>
</Typography>
<Space direction="vertical" size="large" style={{width: "100%"}}>
<>
<Space direction="horizontal" size="large" style={{width: "100%"}}>
<Button
type="primary"
icon={<CloudDownloadOutlined/>}
// onClick={handleCreateBackup}
// disabled={loading}
block
onClick={backupManageTabData.createBackupHandler}
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>
Загрузить бэкап для восстановления
Загрузить резервную копию для восстановления
</Button>
</Upload>
</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>
)}
/>
</>
);
};

View File

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

View File

@ -21,6 +21,7 @@ import {rolesApi} from "../Api/rolesApi.js";
import adminReducer from "./Slices/adminSlice.js";
import {registerApi} from "../Api/registerApi.js";
import {appointmentFilesApi} from "../Api/appointmentFilesApi.js";
import {backupsApi} from "../Api/backupsApi.js";
export const store = configureStore({
reducer: {
@ -60,6 +61,8 @@ export const store = configureStore({
[registerApi.reducerPath]: registerApi.reducer,
[appointmentFilesApi.reducerPath]: appointmentFilesApi.reducer,
[backupsApi.reducerPath]: backupsApi.reducer
},
middleware: (getDefaultMiddleware) => (
getDefaultMiddleware().concat(
@ -77,6 +80,7 @@ export const store = configureStore({
rolesApi.middleware,
registerApi.middleware,
appointmentFilesApi.middleware,
backupsApi.middleware,
)
),
});