From 1a12d389fc4ac702462985f24c8fea9dba1aea0f Mon Sep 17 00:00:00 2001 From: andrei Date: Tue, 1 Jul 2025 18:17:42 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=D0=B5=D1=80=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BF=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/application/backups_repository.py | 32 ++++++++ api/app/controllers/backup_router.py | 69 ++++++++++++++-- api/app/domain/entities/responses/backup.py | 12 +++ api/app/infrastructure/backup_service.py | 89 ++++++++++++++++++--- 4 files changed, 184 insertions(+), 18 deletions(-) create mode 100644 api/app/application/backups_repository.py create mode 100644 api/app/domain/entities/responses/backup.py diff --git a/api/app/application/backups_repository.py b/api/app/application/backups_repository.py new file mode 100644 index 0000000..47bb917 --- /dev/null +++ b/api/app/application/backups_repository.py @@ -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 diff --git a/api/app/controllers/backup_router.py b/api/app/controllers/backup_router.py index 593c41c..659f441 100644 --- a/api/app/controllers/backup_router.py +++ b/api/app/controllers/backup_router.py @@ -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) diff --git a/api/app/domain/entities/responses/backup.py b/api/app/domain/entities/responses/backup.py new file mode 100644 index 0000000..125bc24 --- /dev/null +++ b/api/app/domain/entities/responses/backup.py @@ -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 diff --git a/api/app/infrastructure/backup_service.py b/api/app/infrastructure/backup_service.py index 26a9e7d..d09eb2f 100644 --- a/api/app/infrastructure/backup_service.py +++ b/api/app/infrastructure/backup_service.py @@ -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, + )