from __future__ import annotations from django.db import models class TimestampedModel(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: abstract = True class GlobalConfig(TimestampedModel): name = models.CharField(max_length=64, default="default", unique=True) backup_root = models.CharField(max_length=512) default_ssh_credential = models.ForeignKey( "SshCredential", on_delete=models.SET_NULL, null=True, blank=True, related_name="global_configs", ) ssh_user = models.CharField(max_length=64, default="root") ssh_port = models.PositiveIntegerField(default=22) ssh_options = models.JSONField(default=list, blank=True) rsync_binary = models.CharField(max_length=128, default="rsync") rsync_args = models.JSONField(default=list, blank=True) rsync_extra_args = models.JSONField(default=list, blank=True) rsync_timeout_seconds = models.PositiveIntegerField(default=0) rsync_bwlimit_kbps = models.PositiveIntegerField(default=0) default_source_root = models.CharField(max_length=512, default="/") default_destination_subdir = models.CharField(max_length=512, default="", blank=True) excludes_default = models.JSONField(default=list, blank=True) retention_daily = models.PositiveIntegerField(default=14) retention_weekly = models.PositiveIntegerField(default=8) retention_monthly = models.PositiveIntegerField(default=12) retention_yearly = models.PositiveIntegerField(default=0) class Meta: verbose_name = "global config" verbose_name_plural = "global configs" def __str__(self) -> str: return self.name class HostConfig(TimestampedModel): host = models.CharField(max_length=255, unique=True) address = models.CharField(max_length=255) enabled = models.BooleanField(default=True) ssh_credential = models.ForeignKey( "SshCredential", on_delete=models.SET_NULL, null=True, blank=True, related_name="hosts", ) ssh_user = models.CharField(max_length=64, blank=True) ssh_port = models.PositiveIntegerField(null=True, blank=True) source_root = models.CharField(max_length=512, blank=True) includes = models.JSONField(default=list, blank=True) excludes_add = models.JSONField(default=list, blank=True) excludes_replace = models.JSONField(null=True, blank=True) rsync_extra_args = models.JSONField(default=list, blank=True) rsync_bwlimit_kbps = models.PositiveIntegerField(null=True, blank=True) retention_daily = models.PositiveIntegerField(default=14) retention_weekly = models.PositiveIntegerField(default=8) retention_monthly = models.PositiveIntegerField(default=12) retention_yearly = models.PositiveIntegerField(default=0) config = models.JSONField(default=dict, blank=True) class Meta: ordering = ["host"] def __str__(self) -> str: return self.host class SshCredential(TimestampedModel): name = models.CharField(max_length=128, unique=True) private_key = models.TextField(blank=True, default="") public_key = models.TextField(blank=True) key_path = models.CharField(max_length=1024, blank=True) key_type = models.CharField(max_length=32, default="ed25519") fingerprint = models.CharField(max_length=255, blank=True) generated = models.BooleanField(default=False) known_hosts = models.TextField(blank=True) notes = models.TextField(blank=True) class Meta: ordering = ["name"] def __str__(self) -> str: return self.name class BackupRun(models.Model): class RunType(models.TextChoices): SCHEDULED = "scheduled", "Scheduled" MANUAL = "manual", "Manual" class Status(models.TextChoices): QUEUED = "queued", "Queued" RUNNING = "running", "Running" SUCCESS = "success", "Success" WARNING = "warning", "Warning" FAILED = "failed", "Failed" CANCELLED = "cancelled", "Cancelled" host = models.ForeignKey(HostConfig, on_delete=models.PROTECT, related_name="runs") run_type = models.CharField(max_length=16, choices=RunType.choices, default=RunType.SCHEDULED) status = models.CharField(max_length=16, choices=Status.choices, default=Status.QUEUED) started_at = models.DateTimeField(null=True, blank=True) ended_at = models.DateTimeField(null=True, blank=True) snapshot_path = models.CharField(max_length=1024, blank=True) snapshot = models.ForeignKey( "SnapshotRecord", on_delete=models.SET_NULL, null=True, blank=True, related_name="backup_runs", ) base_path = models.CharField(max_length=1024, blank=True) rsync_exit_code = models.IntegerField(null=True, blank=True) result = models.JSONField(default=dict, blank=True) created_at = models.DateTimeField(auto_now_add=True) reviewed_at = models.DateTimeField(null=True, blank=True) reviewed_by = models.CharField(max_length=150, blank=True) class Meta: ordering = ["-created_at"] def __str__(self) -> str: return f"{self.host} {self.run_type} {self.status}" def default_notification_statuses() -> list[str]: return [ BackupRun.Status.SUCCESS, BackupRun.Status.WARNING, BackupRun.Status.FAILED, BackupRun.Status.CANCELLED, ] class NotificationTarget(TimestampedModel): class Channel(models.TextChoices): EMAIL = "email", "Email" WEBHOOK = "webhook", "Webhook" name = models.CharField(max_length=128, unique=True) enabled = models.BooleanField(default=True) channel = models.CharField(max_length=16, choices=Channel.choices) statuses = models.JSONField(default=default_notification_statuses, blank=True) email_to = models.TextField(blank=True) webhook_url = models.URLField(max_length=1024, blank=True) webhook_headers = models.JSONField(default=dict, blank=True) notes = models.TextField(blank=True) last_status = models.CharField(max_length=16, blank=True) last_error = models.TextField(blank=True) last_sent_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ["name"] def __str__(self) -> str: return self.name class NotificationDelivery(models.Model): class Status(models.TextChoices): SENT = "sent", "Sent" FAILED = "failed", "Failed" SKIPPED = "skipped", "Skipped" target = models.ForeignKey(NotificationTarget, on_delete=models.CASCADE, related_name="deliveries") run = models.ForeignKey(BackupRun, on_delete=models.CASCADE, related_name="notification_deliveries") status = models.CharField(max_length=16, choices=Status.choices) error = models.TextField(blank=True) payload = models.JSONField(default=dict, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: constraints = [ models.UniqueConstraint(fields=["target", "run"], name="unique_notification_delivery_per_target_run"), ] ordering = ["-created_at", "target__name"] verbose_name_plural = "notification deliveries" def __str__(self) -> str: return f"{self.target} run {self.run_id} {self.status}" class SnapshotRecord(models.Model): class Kind(models.TextChoices): SCHEDULED = "scheduled", "Scheduled" MANUAL = "manual", "Manual" INCOMPLETE = "incomplete", "Incomplete" host = models.ForeignKey(HostConfig, on_delete=models.CASCADE, related_name="snapshots") kind = models.CharField(max_length=16, choices=Kind.choices) dirname = models.CharField(max_length=255) path = models.CharField(max_length=1024) base = models.ForeignKey( "self", on_delete=models.SET_NULL, null=True, blank=True, related_name="derived_snapshots", ) base_kind = models.CharField(max_length=16, blank=True) base_dirname = models.CharField(max_length=255, blank=True) base_path = models.CharField(max_length=1024, blank=True) base_snapshot_id = models.CharField(max_length=64, blank=True) status = models.CharField(max_length=32, blank=True) started_at = models.DateTimeField(null=True, blank=True) ended_at = models.DateTimeField(null=True, blank=True) metadata = models.JSONField(default=dict, blank=True) discovered_at = models.DateTimeField(auto_now_add=True) reviewed_at = models.DateTimeField(null=True, blank=True) reviewed_by = models.CharField(max_length=150, blank=True) class Meta: constraints = [ models.UniqueConstraint(fields=["host", "kind", "dirname"], name="unique_snapshot_per_host_kind"), ] ordering = ["host__host", "-started_at", "dirname"] def __str__(self) -> str: return f"{self.host}/{self.kind}/{self.dirname}" class PurgedSnapshot(models.Model): class Action(models.TextChoices): MANUAL = "manual", "Manual" SCHEDULED = "scheduled", "Scheduled" CLI = "cli", "CLI" INCOMPLETE_CLEANUP = "incomplete_cleanup", "Incomplete cleanup" host = models.ForeignKey(HostConfig, on_delete=models.SET_NULL, null=True, blank=True, related_name="purged_snapshots") host_name = models.CharField(max_length=255) kind = models.CharField(max_length=16) dirname = models.CharField(max_length=255) path = models.CharField(max_length=1024) reason = models.CharField(max_length=512, blank=True) action = models.CharField(max_length=32, choices=Action.choices) triggered_by = models.CharField(max_length=150, blank=True) metadata = models.JSONField(default=dict, blank=True) purged_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["-purged_at", "host_name", "dirname"] def __str__(self) -> str: return f"{self.host_name}/{self.kind}/{self.dirname}" class ScheduleConfig(TimestampedModel): host = models.OneToOneField(HostConfig, on_delete=models.CASCADE, related_name="schedule") cron_expr = models.CharField(max_length=128) enabled = models.BooleanField(default=True) prune = models.BooleanField(default=False) prune_max_delete = models.PositiveIntegerField(default=10) prune_protect_bases = models.BooleanField(default=False) last_due_key = models.CharField(max_length=32, blank=True) last_started_at = models.DateTimeField(null=True, blank=True) last_finished_at = models.DateTimeField(null=True, blank=True) last_status = models.CharField(max_length=16, blank=True) class Meta: ordering = ["host__host"] def __str__(self) -> str: return f"{self.host} {self.cron_expr}"