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 fastapi import APIRouter, Depends
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,71 @@ 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.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 os
import tarfile import tarfile
import datetime import datetime
from typing import Any, Coroutine, Optional
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import FileResponse 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: 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
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: 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 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,
)