feat: Добавлена функциональность резервного копирования

This commit is contained in:
Андрей Дувакин 2025-07-01 18:17:42 +05:00
parent 2447bc53af
commit 1a12d389fc
4 changed files with 184 additions and 18 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 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,71 @@ 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.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,12 @@
import datetime
from pydantic import BaseModel
class BackupResponseEntity(BaseModel):
id: int
timestamp: datetime.datetime
path: str
filename: str
user_id: int

View File

@ -2,39 +2,106 @@ import subprocess
import os
import tarfile
import datetime
from typing import Any, Coroutine, Optional
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import FileResponse
from app.application.backups_repository import BackupsRepository
from app.domain.entities.responses.backup import BackupResponseEntity
from app.domain.models import Backup
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 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 model_to_entity(backup: Backup):
return BackupResponseEntity(
id=backup.id,
timestamp=backup.timestamp,
path=backup.path,
filename=backup.filename,
user_id=backup.user_id,
)