import shutil from datetime import datetime, timezone, timedelta from pathlib import Path from typing import List, Optional from sqlalchemy import select from app.models import Backup class RetentionPolicy: def __init__(self, db): self.db = db async def apply_retention_for_job( self, job_id: int, keep_count: Optional[int] = None, keep_days: Optional[int] = None ) -> List[Backup]: """ Apply retention policy for a job's backups. Args: job_id: The job ID to apply retention for keep_count: Maximum number of backups to keep (oldest deleted first) keep_days: Delete backups older than this many days Returns: List of deleted backups """ deleted_backups = [] result = await self.db.execute( select(Backup) .where(Backup.execution.has(job_id=job_id)) .order_by(Backup.created_at.asc()) ) backups = result.scalars().all() if not backups: return deleted_backups backups_to_delete = set() if keep_count is not None and len(backups) > keep_count: backups_to_delete.update(backups[:-keep_count]) if keep_days is not None: cutoff_date = datetime.now(timezone.utc) - timedelta(days=keep_days) for backup in backups: if backup.created_at < cutoff_date: backups_to_delete.add(backup) for backup in list(backups_to_delete): await self._delete_backup(backup) deleted_backups.append(backup) await self.db.commit() return deleted_backups async def _delete_backup(self, backup: Backup): """Delete a backup and its storage.""" try: storage_path = Path(backup.storage_path) if storage_path.exists(): shutil.rmtree(storage_path) except Exception: pass await self.db.delete(backup) async def cleanup_orphaned_backups(self) -> int: """ Remove backup records whose storage no longer exists. Returns: Number of orphaned backups removed """ result = await self.db.execute(select(Backup)) backups = result.scalars().all() removed_count = 0 for backup in backups: storage_path = Path(backup.storage_path) if not storage_path.exists(): await self.db.delete(backup) removed_count += 1 await self.db.commit() return removed_count