from __future__ import annotations import os from datetime import datetime from pathlib import Path from typing import Any, Optional from fastapi import APIRouter, HTTPException, Query, Security, UploadFile, File, status from fastapi.responses import FileResponse from app.services.backup_service import backup_service from ..background.backup_tasks import backup_task_manager from ..dependencies import require_api_token from ..schemas.backups import ( BackupCreateResponse, BackupDeleteResponse, BackupInfo, BackupListResponse, BackupRestoreRequest, BackupRestoreResponse, BackupStatusResponse, BackupTaskInfo, BackupTaskListResponse, ) router = APIRouter() def _parse_datetime(value: Optional[str]) -> Optional[datetime]: if not value: return None try: if value.endswith("Z"): value = value.replace("Z", "+00:00") return datetime.fromisoformat(value) except ValueError: return None def _to_int(value: Any) -> Optional[int]: try: return int(value) except (TypeError, ValueError): return None def _serialize_backup(raw: dict) -> BackupInfo: timestamp = _parse_datetime(raw.get("timestamp")) tables_count = _to_int(raw.get("tables_count")) total_records = _to_int(raw.get("total_records")) file_size_bytes = _to_int(raw.get("file_size_bytes")) or 0 file_size_mb = raw.get("file_size_mb") try: file_size_mb = float(file_size_mb) except (TypeError, ValueError): file_size_mb = round(file_size_bytes / 1024 / 1024, 2) created_by = _to_int(raw.get("created_by")) return BackupInfo( filename=str(raw.get("filename")), filepath=str(raw.get("filepath")), timestamp=timestamp, tables_count=tables_count, total_records=total_records, compressed=bool(raw.get("compressed", False)), file_size_bytes=file_size_bytes, file_size_mb=float(file_size_mb), created_by=created_by, database_type=raw.get("database_type"), version=raw.get("version"), error=raw.get("error"), ) @router.post( "", response_model=BackupCreateResponse, status_code=status.HTTP_202_ACCEPTED, summary="Запустить создание резервной копии", ) async def create_backup_endpoint( token: Any = Security(require_api_token), ) -> BackupCreateResponse: created_by = getattr(token, "id", None) state = await backup_task_manager.enqueue(created_by=created_by) return BackupCreateResponse(task_id=state.task_id, status=state.status) @router.get( "", response_model=BackupListResponse, summary="Список резервных копий", ) async def list_backups( _: Any = Security(require_api_token), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), ) -> BackupListResponse: backups = await backup_service.get_backup_list() total = len(backups) slice_backups = backups[offset : offset + limit] items = [_serialize_backup(raw) for raw in slice_backups] return BackupListResponse( items=items, total=total, limit=limit, offset=offset, ) @router.get( "/status/{task_id}", response_model=BackupStatusResponse, summary="Статус создания резервной копии", ) async def get_backup_status( task_id: str, _: Any = Security(require_api_token), ) -> BackupStatusResponse: state = await backup_task_manager.get(task_id) if not state: raise HTTPException(status.HTTP_404_NOT_FOUND, "Task not found") return BackupStatusResponse( task_id=state.task_id, status=state.status, message=state.message, file_path=state.file_path, created_by=state.created_by, created_at=state.created_at, updated_at=state.updated_at, ) @router.get( "/tasks", response_model=BackupTaskListResponse, summary="Список фоновых задач бекапов", ) async def list_backup_tasks( _: Any = Security(require_api_token), active_only: bool = Query(False, description="Вернуть только активные задачи"), ) -> BackupTaskListResponse: states = await backup_task_manager.list(active_only=active_only) items = [ BackupTaskInfo( task_id=state.task_id, status=state.status, message=state.message, file_path=state.file_path, created_by=state.created_by, created_at=state.created_at, updated_at=state.updated_at, ) for state in states ] return BackupTaskListResponse(items=items, total=len(items)) @router.get( "/download/{filename:path}", summary="Скачать файл резервной копии", responses={ 200: { "content": {"application/octet-stream": {}}, "description": "Файл резервной копии", } }, ) async def download_backup( filename: str, _: Any = Security(require_api_token), ) -> FileResponse: backup_path = backup_service.backup_dir / filename if not backup_path.exists(): raise HTTPException(status.HTTP_404_NOT_FOUND, "Backup file not found") if not backup_path.is_file(): raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid backup path") resolved_path = backup_path.resolve() backup_dir_resolved = backup_service.backup_dir.resolve() if not str(resolved_path).startswith(str(backup_dir_resolved)): raise HTTPException(status.HTTP_403_FORBIDDEN, "Access denied") return FileResponse( path=str(backup_path), filename=filename, media_type="application/octet-stream", ) @router.post( "/restore/{filename:path}", response_model=BackupRestoreResponse, summary="Восстановить из резервной копии", ) async def restore_backup( filename: str, payload: BackupRestoreRequest, _: Any = Security(require_api_token), ) -> BackupRestoreResponse: backup_path = backup_service.backup_dir / filename if not backup_path.exists(): raise HTTPException(status.HTTP_404_NOT_FOUND, "Backup file not found") resolved_path = backup_path.resolve() backup_dir_resolved = backup_service.backup_dir.resolve() if not str(resolved_path).startswith(str(backup_dir_resolved)): raise HTTPException(status.HTTP_403_FORBIDDEN, "Access denied") success, message = await backup_service.restore_backup( str(backup_path), clear_existing=payload.clear_existing ) return BackupRestoreResponse( success=success, message=message, ) @router.post( "/upload", response_model=BackupRestoreResponse, summary="Загрузить и восстановить из файла резервной копии", ) async def upload_and_restore_backup( file: UploadFile = File(..., description="Файл резервной копии (.tar.gz, .json, .json.gz)"), clear_existing: bool = Query(False, description="Очистить существующие данные"), _: Any = Security(require_api_token), ) -> BackupRestoreResponse: if not file.filename: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Filename is required") safe_filename = Path(file.filename).name if not safe_filename or safe_filename in ('.', '..'): raise HTTPException(status.HTTP_400_BAD_REQUEST, "Invalid filename") allowed_extensions = ('.tar.gz', '.json', '.json.gz', '.tar') if not any(safe_filename.endswith(ext) for ext in allowed_extensions): raise HTTPException( status.HTTP_400_BAD_REQUEST, f"Invalid file type. Allowed: {', '.join(allowed_extensions)}" ) temp_path = backup_service.backup_dir / f"uploaded_{safe_filename}" resolved_path = temp_path.resolve() backup_dir_resolved = backup_service.backup_dir.resolve() if not str(resolved_path).startswith(str(backup_dir_resolved)): raise HTTPException(status.HTTP_403_FORBIDDEN, "Invalid file path") try: content = await file.read() with open(temp_path, 'wb') as f: f.write(content) success, message = await backup_service.restore_backup( str(temp_path), clear_existing=clear_existing ) return BackupRestoreResponse( success=success, message=message, ) finally: if temp_path.exists(): try: temp_path.unlink() except Exception: pass @router.delete( "/{filename:path}", response_model=BackupDeleteResponse, summary="Удалить резервную копию", ) async def delete_backup( filename: str, _: Any = Security(require_api_token), ) -> BackupDeleteResponse: backup_path = backup_service.backup_dir / filename resolved_path = backup_path.resolve() backup_dir_resolved = backup_service.backup_dir.resolve() if not str(resolved_path).startswith(str(backup_dir_resolved)): raise HTTPException(status.HTTP_403_FORBIDDEN, "Access denied") success, message = await backup_service.delete_backup(filename) if not success: raise HTTPException(status.HTTP_404_NOT_FOUND, message) return BackupDeleteResponse( success=success, message=message, )