feat: Добавлена функция восстановления бэкапов

Внесены изменения в API и веб-приложение для поддержки восстановления резервных копий данных. Добавлена новая мутация API для восстановления бэкапов. Добавлена кнопка восстановления в веб-приложении. Добавлена функция прерывания всех запросов при восстановлении бэкапа.
This commit is contained in:
Андрей Дувакин 2025-07-03 08:38:26 +05:00
parent 04b9682f1e
commit 8042460557
26 changed files with 277 additions and 96 deletions

View File

@ -2,7 +2,7 @@ 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
from app.database.session import get_db from app.database.session import get_db, engine
from app.domain.entities.responses.backup import BackupResponseEntity 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
@ -79,6 +79,27 @@ async def upload_backup(
return await backup_service.upload_backup(file, user) return await backup_service.upload_backup(file, user)
@router.post(
'/{backup_id}/restore/',
response_model=BackupResponseEntity,
summary='Restore a backup',
description='Restore a backup by ID, overwriting existing data',
)
async def restore_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.restore_backup(backup_id, engine)
@router.delete( @router.delete(
'/{backup_id}/', '/{backup_id}/',
response_model=BackupResponseEntity, response_model=BackupResponseEntity,

View File

@ -2,14 +2,16 @@ from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.settings import settings
class AppointmentFile(BaseModel): class AppointmentFile(BaseModel):
__tablename__ = 'appointment_files' __tablename__ = 'appointment_files'
__table_args__ = {"schema": settings.SCHEMA}
file_path = Column(String, nullable=False) file_path = Column(String, nullable=False)
file_title = Column(String, nullable=False) file_title = Column(String, nullable=False)
appointment_id = Column(Integer, ForeignKey('appointments.id'), nullable=False) appointment_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.appointments.id'), nullable=False)
appointment = relationship('Appointment', back_populates='files') appointment = relationship('Appointment', back_populates='files')

View File

@ -2,10 +2,12 @@ from sqlalchemy import Column, VARCHAR
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.settings import settings
class AppointmentType(BaseModel): class AppointmentType(BaseModel):
__tablename__ = 'appointment_types' __tablename__ = 'appointment_types'
__table_args__ = {"schema": settings.SCHEMA}
title = Column(VARCHAR(150), nullable=False, unique=True) title = Column(VARCHAR(150), nullable=False, unique=True)

View File

@ -3,18 +3,20 @@ from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.settings import settings
class Appointment(BaseModel): class Appointment(BaseModel):
__tablename__ = 'appointments' __tablename__ = 'appointments'
__table_args__ = {"schema": settings.SCHEMA}
results = Column(String) results = Column(String)
days_until_the_next_appointment = Column(Integer) days_until_the_next_appointment = Column(Integer)
appointment_datetime = Column(DateTime, nullable=False, server_default=func.now()) appointment_datetime = Column(DateTime, nullable=False, server_default=func.now())
patient_id = Column(Integer, ForeignKey('patients.id'), nullable=False) patient_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.patients.id'), nullable=False)
doctor_id = Column(Integer, ForeignKey('users.id'), nullable=False) doctor_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.users.id'), nullable=False)
type_id = Column(Integer, ForeignKey('appointment_types.id'), nullable=False) type_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.appointment_types.id'), nullable=False)
patient = relationship('Patient', back_populates='appointments') patient = relationship('Patient', back_populates='appointments')
doctor = relationship('User', back_populates='appointments') doctor = relationship('User', back_populates='appointments')

View File

@ -3,14 +3,16 @@ import datetime
from sqlalchemy import Column, DateTime, String, Integer, ForeignKey, Boolean from sqlalchemy import Column, DateTime, String, Integer, ForeignKey, Boolean
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.settings import settings
class Backup(BaseModel): class Backup(BaseModel):
__tablename__ = 'backups' __tablename__ = 'backups'
__table_args__ = {"schema": settings.SCHEMA}
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) is_by_user = Column(Boolean, nullable=False, default=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False) user_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.users.id'), nullable=False)

View File

@ -4,6 +4,7 @@ from sqlalchemy import Column, Integer, ForeignKey, Float, Enum, Boolean
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.settings import settings
class SideEnum(PyEnum): class SideEnum(PyEnum):
@ -13,6 +14,7 @@ class SideEnum(PyEnum):
class Lens(BaseModel): class Lens(BaseModel):
__tablename__ = 'lens' __tablename__ = 'lens'
__table_args__ = {"schema": settings.SCHEMA}
tor = Column(Float, nullable=False) tor = Column(Float, nullable=False)
trial = Column(Float, nullable=False) trial = Column(Float, nullable=False)
@ -24,7 +26,7 @@ class Lens(BaseModel):
side = Column(Enum(SideEnum), nullable=False) side = Column(Enum(SideEnum), nullable=False)
issued = Column(Boolean, nullable=False, default=False) issued = Column(Boolean, nullable=False, default=False)
type_id = Column(Integer, ForeignKey('lens_types.id'), nullable=False) type_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.lens_types.id'), nullable=False)
type = relationship('LensType', back_populates='lenses') type = relationship('LensType', back_populates='lenses')

View File

@ -2,16 +2,18 @@ from sqlalchemy import Column, Integer, ForeignKey, Date
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.settings import settings
class LensIssue(BaseModel): class LensIssue(BaseModel):
__tablename__ = 'lens_issues' __tablename__ = 'lens_issues'
__table_args__ = {"schema": settings.SCHEMA}
issue_date = Column(Date, nullable=False) issue_date = Column(Date, nullable=False)
patient_id = Column(Integer, ForeignKey('patients.id'), nullable=False) patient_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.patients.id'), nullable=False)
doctor_id = Column(Integer, ForeignKey('users.id'), nullable=False) doctor_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.users.id'), nullable=False)
lens_id = Column(Integer, ForeignKey('lens.id'), nullable=False) lens_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.lens.id'), nullable=False)
patient = relationship('Patient', back_populates='lens_issues') patient = relationship('Patient', back_populates='lens_issues')
doctor = relationship('User', back_populates='lens_issues') doctor = relationship('User', back_populates='lens_issues')

View File

@ -2,10 +2,12 @@ from sqlalchemy import Column, Integer, VARCHAR
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.settings import settings
class LensType(BaseModel): class LensType(BaseModel):
__tablename__ = 'lens_types' __tablename__ = 'lens_types'
__table_args__ = {"schema": settings.SCHEMA}
title = Column(VARCHAR(150), nullable=False, unique=True) title = Column(VARCHAR(150), nullable=False, unique=True)

View File

@ -3,16 +3,18 @@ from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.settings import settings
class Mailing(BaseModel): class Mailing(BaseModel):
__tablename__ = 'mailing' __tablename__ = 'mailing'
__table_args__ = {"schema": settings.SCHEMA}
text = Column(String, nullable=False) text = Column(String, nullable=False)
title = Column(String, nullable=False) title = Column(String, nullable=False)
datetime = Column(DateTime, nullable=False, default=func.utcnow) datetime = Column(DateTime, nullable=False, default=func.utcnow)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False) user_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.users.id'), nullable=False)
user = relationship('User', back_populates='mailing') user = relationship('User', back_populates='mailing')

View File

@ -2,10 +2,12 @@ from sqlalchemy import Column, Integer, VARCHAR
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.settings import settings
class MailingDeliveryMethod(BaseModel): class MailingDeliveryMethod(BaseModel):
__tablename__ = 'mailing_delivery_methods' __tablename__ = 'mailing_delivery_methods'
__table_args__ = {"schema": settings.SCHEMA}
title = Column(VARCHAR(200), nullable=False) title = Column(VARCHAR(200), nullable=False)

View File

@ -2,13 +2,15 @@ from sqlalchemy import Column, Integer, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.settings import settings
class MailingOption(BaseModel): class MailingOption(BaseModel):
__tablename__ = 'mailing_options' __tablename__ = 'mailing_options'
__table_args__ = {"schema": settings.SCHEMA}
option_id = Column(Integer, ForeignKey('mailing_delivery_methods.id'), nullable=False) option_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.mailing_delivery_methods.id'), nullable=False)
mailing_id = Column(Integer, ForeignKey('mailing.id'), nullable=False) mailing_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.mailing.id'), nullable=False)
method = relationship('MailingDeliveryMethod', back_populates='mailing') method = relationship('MailingDeliveryMethod', back_populates='mailing')
mailing = relationship('Mailing', back_populates='mailing_options') mailing = relationship('Mailing', back_populates='mailing_options')

View File

@ -2,10 +2,12 @@ from sqlalchemy import Column, VARCHAR, Date, String
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.settings import settings
class Patient(BaseModel): class Patient(BaseModel):
__tablename__ = 'patients' __tablename__ = 'patients'
__table_args__ = {"schema": settings.SCHEMA}
first_name = Column(VARCHAR(200), nullable=False) first_name = Column(VARCHAR(200), nullable=False)
last_name = Column(VARCHAR(200), nullable=False) last_name = Column(VARCHAR(200), nullable=False)

View File

@ -2,13 +2,15 @@ from sqlalchemy import Column, Integer, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.settings import settings
class Recipient(BaseModel): class Recipient(BaseModel):
__tablename__ = 'recipients' __tablename__ = 'recipients'
__table_args__ = {"schema": settings.SCHEMA}
patient_id = Column(Integer, ForeignKey('patients.id'), nullable=False) patient_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.patients.id'), nullable=False)
mailing_id = Column(Integer, ForeignKey('mailing.id'), nullable=False) mailing_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.mailing.id'), nullable=False)
patient = relationship('Patient', back_populates='mailing') patient = relationship('Patient', back_populates='mailing')
mailing = relationship('Mailing', back_populates='recipients') mailing = relationship('Mailing', back_populates='recipients')

View File

@ -2,10 +2,12 @@ from sqlalchemy import Column, VARCHAR
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.settings import settings
class Role(BaseModel): class Role(BaseModel):
__tablename__ = 'roles' __tablename__ = 'roles'
__table_args__ = {"schema": settings.SCHEMA}
title = Column(VARCHAR(150), nullable=False, unique=True) title = Column(VARCHAR(150), nullable=False, unique=True)

View File

@ -3,17 +3,19 @@ from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.settings import settings
class ScheduledAppointment(BaseModel): class ScheduledAppointment(BaseModel):
__tablename__ = 'scheduled_appointments' __tablename__ = 'scheduled_appointments'
__table_args__ = {"schema": settings.SCHEMA}
scheduled_datetime = Column(DateTime, nullable=False, server_default=func.now()) scheduled_datetime = Column(DateTime, nullable=False, server_default=func.now())
is_canceled = Column(Boolean, nullable=False, default=False, server_default='false') is_canceled = Column(Boolean, nullable=False, default=False, server_default='false')
patient_id = Column(Integer, ForeignKey('patients.id'), nullable=False) patient_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.patients.id'), nullable=False)
doctor_id = Column(Integer, ForeignKey('users.id'), nullable=False) doctor_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.users.id'), nullable=False)
type_id = Column(Integer, ForeignKey('appointment_types.id'), nullable=False) type_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.appointment_types.id'), nullable=False)
patient = relationship('Patient', back_populates='scheduled_appointments') patient = relationship('Patient', back_populates='scheduled_appointments')
doctor = relationship('User', back_populates='scheduled_appointments') doctor = relationship('User', back_populates='scheduled_appointments')

View File

@ -3,10 +3,12 @@ from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.domain.models.lens import SideEnum from app.domain.models.lens import SideEnum
from app.settings import settings
class SetContent(BaseModel): class SetContent(BaseModel):
__tablename__ = 'set_contents' __tablename__ = 'set_contents'
__table_args__ = {"schema": settings.SCHEMA}
tor = Column(Float, nullable=False) tor = Column(Float, nullable=False)
trial = Column(Float, nullable=False) trial = Column(Float, nullable=False)
@ -18,8 +20,8 @@ class SetContent(BaseModel):
side = Column(Enum(SideEnum), nullable=False) side = Column(Enum(SideEnum), nullable=False)
count = Column(Integer, nullable=False) count = Column(Integer, nullable=False)
type_id = Column(Integer, ForeignKey('lens_types.id'), nullable=False) type_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.lens_types.id'), nullable=False)
set_id = Column(Integer, ForeignKey('sets.id'), nullable=False) set_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.sets.id'), nullable=False)
type = relationship('LensType', back_populates='contents') type = relationship('LensType', back_populates='contents')
set = relationship('Set', back_populates='contents') set = relationship('Set', back_populates='contents')

View File

@ -2,10 +2,12 @@ from sqlalchemy import Column, Integer, VARCHAR
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.settings import settings
class Set(BaseModel): class Set(BaseModel):
__tablename__ = 'sets' __tablename__ = 'sets'
__table_args__ = {"schema": settings.SCHEMA}
title = Column(VARCHAR(150), nullable=False, unique=True) title = Column(VARCHAR(150), nullable=False, unique=True)

View File

@ -3,10 +3,12 @@ from sqlalchemy.orm import relationship
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
from app.domain.models.base import BaseModel from app.domain.models.base import BaseModel
from app.settings import settings
class User(BaseModel): class User(BaseModel):
__tablename__ = 'users' __tablename__ = 'users'
__table_args__ = {"schema": settings.SCHEMA}
first_name = Column(VARCHAR(200), nullable=False) first_name = Column(VARCHAR(200), nullable=False)
last_name = Column(VARCHAR(200), nullable=False) last_name = Column(VARCHAR(200), nullable=False)
@ -15,7 +17,7 @@ class User(BaseModel):
password = Column(String, nullable=False) password = Column(String, nullable=False)
is_blocked = Column(Boolean, nullable=False, default=False, server_default='false') is_blocked = Column(Boolean, nullable=False, default=False, server_default='false')
role_id = Column(Integer, ForeignKey('roles.id'), nullable=False) role_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.roles.id'), nullable=False)
role = relationship('Role', back_populates='users') role = relationship('Role', back_populates='users')

View File

@ -1,14 +1,16 @@
import io
import subprocess
import os
import tarfile
import datetime import datetime
from typing import Any, Coroutine, Optional import io
import os
import shutil
import subprocess
import tarfile
from typing import Optional
from fastapi_maintenance import maintenance_mode_on
import aiofiles import aiofiles
from fastapi import HTTPException, UploadFile from fastapi import HTTPException, UploadFile
from magic import magic from magic import magic
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine
from starlette.responses import FileResponse from starlette.responses import FileResponse
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
@ -69,6 +71,58 @@ class BackupService:
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
raise HTTPException(500, f"Ошибка создания бэкапа: {e}") raise HTTPException(500, f"Ошибка создания бэкапа: {e}")
async def restore_backup(self, backup_id: int, engine: AsyncEngine) -> BackupResponseEntity:
try:
async with maintenance_mode_on():
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, 'Файл не найден на диске')
with tarfile.open(backup.path, "r:gz") as tar:
members = tar.getnames()
if "db_dump.sql" not in members or not any(
name.startswith(os.path.basename(self.app_files_dir)) for name in members):
raise HTTPException(400, "Неверная структура архива резервной копии")
if os.path.exists(self.app_files_dir):
shutil.rmtree(self.app_files_dir)
os.makedirs(self.app_files_dir, exist_ok=True)
await engine.dispose()
psql_path = self.pg_dump_path.replace("pg_dump", "psql")
drop_cmd = (
f'"{psql_path}" '
f'-d "{self.db_url}" '
f'-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"'
)
subprocess.run(drop_cmd, shell=True, check=True)
temp_dir = os.path.join(self.backup_dir, f"temp_restore_{backup_id}")
os.makedirs(temp_dir, exist_ok=True)
with tarfile.open(backup.path, "r:gz") as tar:
tar.extractall(temp_dir)
db_dump_path = os.path.join(temp_dir, "db_dump.sql")
restore_cmd = f'"{self.pg_dump_path.replace("pg_dump", "pg_restore")}" -d {self.db_url} --no-owner --no-privileges -Fc "{db_dump_path}"'
subprocess.run(restore_cmd, shell=True, check=True)
extracted_app_files_dir = os.path.join(temp_dir, os.path.basename(self.app_files_dir))
if os.path.exists(extracted_app_files_dir):
shutil.copytree(extracted_app_files_dir, self.app_files_dir, dirs_exist_ok=True)
shutil.rmtree(temp_dir)
return self.model_to_entity(backup)
except subprocess.CalledProcessError as e:
raise HTTPException(500, f"Ошибка восстановления бэкапа: {e}")
except Exception as e:
raise HTTPException(500, f"Ошибка сервера: {str(e)}")
async def get_backup_file_by_id(self, backup_id: int) -> FileResponse: async def get_backup_file_by_id(self, backup_id: int) -> FileResponse:
backup = await self.backup_repository.get_by_id(backup_id) backup = await self.backup_repository.get_by_id(backup_id)

View File

@ -1,4 +1,5 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi_maintenance import MaintenanceModeMiddleware
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from app.controllers.appointment_files_router import router as appointment_files_router from app.controllers.appointment_files_router import router as appointment_files_router
@ -29,6 +30,7 @@ def start_app():
allow_methods=['*'], allow_methods=['*'],
allow_headers=['*'], allow_headers=['*'],
) )
# api_app.add_middleware(MaintenanceModeMiddleware, enable_maintenance=False)
api_app.include_router(appointment_files_router, prefix=f'{settings.APP_PREFIX}/appointment_files', api_app.include_router(appointment_files_router, prefix=f'{settings.APP_PREFIX}/appointment_files',
tags=['appointment_files']) tags=['appointment_files'])

View File

@ -12,6 +12,7 @@ class Settings(BaseSettings):
BACKUP_DIR: str = 'backups' BACKUP_DIR: str = 'backups'
BACKUP_DB_URL: str BACKUP_DB_URL: str
PG_DUMP_PATH: str PG_DUMP_PATH: str
SCHEMA: str = 'public'
class Config: class Config:
env_file = '.env' env_file = '.env'

View File

@ -12,3 +12,4 @@ pyjwt==2.10.1
python-magic==0.4.27 python-magic==0.4.27
aiofiles==24.1.0 aiofiles==24.1.0
python-multipart==0.0.20 python-multipart==0.0.20
fastapi-maintenance==0.0.4

View File

@ -40,7 +40,12 @@ export const backupsApi = createApi({
}, },
invalidatesTags: ['Backup'], invalidatesTags: ['Backup'],
}), }),
restoreBackup: builder.mutation({
query: (backupId) => ({
url: `/backups/${backupId}/restore/`,
method: 'POST',
}),
}),
}), }),
}); });
@ -49,4 +54,5 @@ export const {
useCreateBackupMutation, useCreateBackupMutation,
useDeleteBackupMutation, useDeleteBackupMutation,
useUploadBackupMutation, useUploadBackupMutation,
useRestoreBackupMutation,
} = backupsApi; } = backupsApi;

View File

@ -1,29 +1,29 @@
import { useDispatch, useSelector } from "react-redux"; import {useDispatch, useSelector} from "react-redux";
import { setSelectedAppointment } from "../../../Redux/Slices/appointmentsSlice.js"; import {setSelectedAppointment} from "../../../Redux/Slices/appointmentsSlice.js";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useState } from "react"; import {useState} from "react";
import { useGetAppointmentFilesQuery, useDeleteAppointmentFileMutation } from "../../../Api/appointmentFilesApi.js"; import {useGetAppointmentFilesQuery, useDeleteAppointmentFileMutation} from "../../../Api/appointmentFilesApi.js";
import { baseQueryWithAuth } from "../../../Api/baseQuery.js"; import {notification} from "antd";
import { notification } from "antd"; import CONFIG from "../../../Core/сonfig.js";
const useAppointmentView = () => { const useAppointmentView = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { selectedAppointment } = useSelector((state) => state.appointmentsUI); const {selectedAppointment} = useSelector((state) => state.appointmentsUI);
const { data: files = [], isLoading: isFilesLoading } = useGetAppointmentFilesQuery( const {data: files = [], isLoading: isFilesLoading} = useGetAppointmentFilesQuery(
selectedAppointment?.id, selectedAppointment?.id,
{ skip: !selectedAppointment?.id } {skip: !selectedAppointment?.id}
); );
const [deleteAppointmentFile, { isLoading: isDeletingFile }] = useDeleteAppointmentFileMutation(); const [deleteAppointmentFile, {isLoading: isDeletingFile}] = useDeleteAppointmentFileMutation();
const [downloadingFiles, setDownloadingFiles] = useState({}); const [downloadingFiles, setDownloadingFiles] = useState({});
const [deletingFiles, setDeletingFiles] = useState({}); const [deletingFiles, setDeletingFiles] = useState({});
const modalWidth = 700; const modalWidth = 700;
const blockStyle = { marginBottom: 16 }; const blockStyle = {marginBottom: 16};
const footerRowStyle = { marginTop: 16 }; const footerRowStyle = {marginTop: 16};
const footerButtonStyle = { marginRight: 8 }; const footerButtonStyle = {marginRight: 8};
const labels = { const labels = {
title: "Просмотр приема", title: "Просмотр приема",
@ -81,54 +81,84 @@ const useAppointmentView = () => {
const downloadFile = async (fileId, fileName) => { const downloadFile = async (fileId, fileName) => {
try { try {
setDownloadingFiles((prev) => ({ ...prev, [fileId]: true })); setDownloadingFiles((prev) => ({ ...prev, [fileId]: true }));
const { url, ...options } = await baseQueryWithAuth(
{
url: `/appointment_files/${fileId}/file/`,
method: 'GET',
credentials: 'include',
},
{},
{}
);
const response = await fetch(url, { const token = localStorage.getItem('access_token');
...options, if (!token) {
method: 'GET',
credentials: 'include',
});
if (!response.ok) {
notification.error({ notification.error({
message: "Ошибка при скачивании файла", message: "Ошибка",
description: "Не удалось загрузить файл.", description: "Токен не найден",
placement: "topRight", placement: "topRight",
}); });
return;
}
const response = await fetch(`${CONFIG.BASE_URL}appointment_files/${fileId}/file/`, {
method: 'GET',
credentials: 'include',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
const errorText = await response.text();
notification.error({
message: "Ошибка",
description: errorText || "Не удалось скачать файл",
placement: "topRight",
});
return;
}
const contentType = response.headers.get('content-type');
if (!contentType || contentType.includes('text/html')) {
const errorText = await response.text();
notification.error({
message: "Ошибка",
description: errorText || "Не удалось скачать файл",
placement: "topRight",
});
return;
}
let safeFileName = fileName || "file";
if (!safeFileName.match(/\.[a-zA-Z0-9]+$/)) {
if (contentType.includes('application/pdf')) {
safeFileName += '.pdf';
} else if (contentType.includes('image/jpeg')) {
safeFileName += '.jpg';
} else if (contentType.includes('image/png')) {
safeFileName += '.png';
} else {
safeFileName += '.bin';
}
} }
const blob = await response.blob(); const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob); const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
link.href = downloadUrl; link.href = downloadUrl;
link.setAttribute("download", fileName || "file"); link.setAttribute("download", safeFileName);
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
link.remove(); link.remove();
window.URL.revokeObjectURL(downloadUrl); window.URL.revokeObjectURL(downloadUrl);
} catch (error) { } catch (error) {
console.error("Error downloading file:", error); console.error("Error downloading file:", error); // Отладка
notification.error({ notification.error({
message: "Ошибка при скачивании файлов", message: "Ошибка",
description: "Не удалось загрузить файл.", description: error.message || "Не удалось скачать файл",
placement: "topRight", placement: "topRight",
}); });
} finally { } finally {
setDownloadingFiles((prev) => ({ ...prev, [fileId]: false })); setDownloadingFiles((prev) => ({...prev, [fileId]: false}));
} }
}; };
const deleteFile = async (fileId, fileName) => { const deleteFile = async (fileId, fileName) => {
try { try {
setDeletingFiles((prev) => ({ ...prev, [fileId]: true })); setDeletingFiles((prev) => ({...prev, [fileId]: true}));
await deleteAppointmentFile(fileId).unwrap(); await deleteAppointmentFile(fileId).unwrap();
notification.success({ notification.success({
message: "Файл удален", message: "Файл удален",
@ -143,7 +173,7 @@ const useAppointmentView = () => {
placement: "topRight", placement: "topRight",
}); });
} finally { } finally {
setDeletingFiles((prev) => ({ ...prev, [fileId]: false })); setDeletingFiles((prev) => ({...prev, [fileId]: false}));
} }
}; };

View File

@ -1,5 +1,5 @@
import {Button, Divider, List, Result, Space, Tooltip, Typography, Upload} from "antd"; import {Button, Divider, List, Popconfirm, Result, Space, Tooltip, Typography, Upload} from "antd";
import {CloudDownloadOutlined, DeleteOutlined, UploadOutlined} from "@ant-design/icons"; import {CloudDownloadOutlined, DeleteOutlined, UploadOutlined, ReloadOutlined} 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";
@ -11,7 +11,7 @@ const BackupManageTab = () => {
} }
if (backupManageTabData.isErrorBackups) { if (backupManageTabData.isErrorBackups) {
return <Result status={500} title="Произошла ошибка при загрузке резервных копий"/> return <Result status={500} title="Произошла ошибка при загрузке резервных копий"/>;
} }
return ( return (
@ -22,7 +22,6 @@ const BackupManageTab = () => {
icon={<CloudDownloadOutlined/>} icon={<CloudDownloadOutlined/>}
onClick={backupManageTabData.createBackupHandler} onClick={backupManageTabData.createBackupHandler}
loading={backupManageTabData.isCreatingBackup} loading={backupManageTabData.isCreatingBackup}
> >
Создать резервную копию Создать резервную копию
</Button> </Button>
@ -46,7 +45,9 @@ const BackupManageTab = () => {
renderItem={(backup) => ( renderItem={(backup) => (
<List.Item> <List.Item>
<Typography.Text>{backup.filename}</Typography.Text> <Typography.Text>{backup.filename}</Typography.Text>
<Divider type={"vertical"}/>
<Typography.Text>{backup.is_by_user ? "Загружена" : "Создана"}: {new Date(backup.timestamp).toLocaleString()}</Typography.Text> <Typography.Text>{backup.is_by_user ? "Загружена" : "Создана"}: {new Date(backup.timestamp).toLocaleString()}</Typography.Text>
<Space>
<Tooltip title="Скачать резервную копию"> <Tooltip title="Скачать резервную копию">
<Button <Button
type="primary" type="primary"
@ -57,6 +58,18 @@ const BackupManageTab = () => {
Скачать Скачать
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip title="Восстановить резервную копию">
<Popconfirm title={"Вы действительно хотите восстановить резервную копию? Существующие данные будут утеряны навсегда."}
onConfirm={() => backupManageTabData.restoreBackupHandler(backup.id)}>
<Button
type="primary"
icon={<ReloadOutlined/>}
loading={backupManageTabData.isRestoringBackup}
>
Восстановить
</Button>
</Popconfirm>
</Tooltip>
<Tooltip title="Удалить резервную копию"> <Tooltip title="Удалить резервную копию">
<Button <Button
type="primary" type="primary"
@ -68,11 +81,11 @@ const BackupManageTab = () => {
Удалить Удалить
</Button> </Button>
</Tooltip> </Tooltip>
</Space>
</List.Item> </List.Item>
)} )}
/> />
</> </>
); );
}; };

View File

@ -1,7 +1,9 @@
import { import {
useCreateBackupMutation, useCreateBackupMutation,
useDeleteBackupMutation, useDeleteBackupMutation,
useGetBackupsQuery, useUploadBackupMutation useGetBackupsQuery,
useUploadBackupMutation,
useRestoreBackupMutation,
} 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";
@ -10,13 +12,13 @@ import CONFIG from "../../../../../Core/сonfig.js";
const useBackupManageTab = () => { const useBackupManageTab = () => {
const dispatch = useDispatch();
const {data: backups, isLoading: isLoadingBackups, isError: isErrorBackups} = useGetBackupsQuery(undefined, { const {data: backups, isLoading: isLoadingBackups, isError: isErrorBackups} = useGetBackupsQuery(undefined, {
pollingInterval: 60000, pollingInterval: 60000,
}); });
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 [uploadBackup, {isLoading: isUploadingBackup}] = useUploadBackupMutation();
const [restoreBackup, {isLoading: isRestoringBackup}] = useRestoreBackupMutation();
const [isDownloadingBackup, setDownloadingFiles] = useState(false); const [isDownloadingBackup, setDownloadingFiles] = useState(false);
@ -107,6 +109,20 @@ const useBackupManageTab = () => {
} }
}; };
const restoreBackupHandler = async (backupId) => {
try {
await restoreBackup(backupId).unwrap();
notification.success({
message: "Успех", description: "Резервная копия успешно восстановлена", placement: "topRight",
});
} catch (e) {
notification.error({
message: "Ошибка",
description: e?.data?.detail || "Не удалось восстановить резервную копию",
placement: "topRight",
});
}
};
const deleteBackupHandler = async (backupId) => { const deleteBackupHandler = async (backupId) => {
try { try {
@ -142,6 +158,7 @@ const useBackupManageTab = () => {
placement: "topRight", placement: "topRight",
}); });
} catch (e) { } catch (e) {
console.error("Upload error:", e); // Отладка
notification.error({ notification.error({
message: "Ошибка", message: "Ошибка",
description: e.message || e?.data?.detail || "Не удалось загрузить резервную копию", description: e.message || e?.data?.detail || "Не удалось загрузить резервную копию",
@ -158,11 +175,13 @@ const useBackupManageTab = () => {
isDownloadingBackup, isDownloadingBackup,
isDeletingBackup, isDeletingBackup,
isUploadingBackup, isUploadingBackup,
isRestoringBackup,
createBackupHandler, createBackupHandler,
downloadBackupHandler, downloadBackupHandler,
restoreBackupHandler,
deleteBackupHandler, deleteBackupHandler,
uploadBackupHandler, uploadBackupHandler,
} };
}; };
export default useBackupManageTab; export default useBackupManageTab;