From 5808800981bbdfbdb3cb57727c6b1c89cc466a91 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Tue, 19 May 2026 11:13:06 +0200 Subject: [PATCH] feat: link backup runs to snapshot records Add a nullable SnapshotRecord foreign key to BackupRun and populate it when run_pobsync_backup records a completed or failed snapshot. Keep the existing snapshot_path for audit compatibility while making run-to-snapshot navigation explicit in the database and admin. --- src/pobsync_backend/admin.py | 5 ++-- .../management/commands/run_pobsync_backup.py | 17 +++++++------ .../migrations/0004_backuprun_snapshot.py | 24 +++++++++++++++++++ src/pobsync_backend/models.py | 7 ++++++ .../tests/test_run_backup_records_snapshot.py | 4 ++++ 5 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 src/pobsync_backend/migrations/0004_backuprun_snapshot.py diff --git a/src/pobsync_backend/admin.py b/src/pobsync_backend/admin.py index 917c169..e4cb08a 100644 --- a/src/pobsync_backend/admin.py +++ b/src/pobsync_backend/admin.py @@ -50,9 +50,10 @@ class HostConfigAdmin(admin.ModelAdmin): @admin.register(BackupRun) class BackupRunAdmin(admin.ModelAdmin): - list_display = ("host", "run_type", "status", "started_at", "ended_at", "snapshot_path") + list_display = ("host", "run_type", "status", "started_at", "ended_at", "snapshot") list_filter = ("run_type", "status", "started_at") - search_fields = ("host__host", "snapshot_path") + search_fields = ("host__host", "snapshot_path", "snapshot__dirname", "snapshot__path") + autocomplete_fields = ("snapshot",) @admin.register(SnapshotRecord) diff --git a/src/pobsync_backend/management/commands/run_pobsync_backup.py b/src/pobsync_backend/management/commands/run_pobsync_backup.py index 9933edd..2a4faed 100644 --- a/src/pobsync_backend/management/commands/run_pobsync_backup.py +++ b/src/pobsync_backend/management/commands/run_pobsync_backup.py @@ -64,23 +64,26 @@ class Command(BaseCommand): rsync = result.get("rsync") if isinstance(result.get("rsync"), dict) else {} run.rsync_exit_code = rsync.get("exit_code") run.result = result + snapshot_record = None + if run.snapshot_path: + snapshot_path = Path(run.snapshot_path) + try: + kind = infer_snapshot_kind(snapshot_path) + snapshot_record, _created = upsert_snapshot_record(host=host, kind=kind, snapshot_dir=snapshot_path) + except ValueError: + snapshot_record = None + run.snapshot = snapshot_record run.save( update_fields=[ "status", "ended_at", "snapshot_path", + "snapshot", "base_path", "rsync_exit_code", "result", ], ) - if run.snapshot_path: - snapshot_path = Path(run.snapshot_path) - try: - kind = infer_snapshot_kind(snapshot_path) - upsert_snapshot_record(host=host, kind=kind, snapshot_dir=snapshot_path) - except ValueError: - pass if result.get("ok"): self.stdout.write(self.style.SUCCESS(f"Backup completed for {host.host}.")) diff --git a/src/pobsync_backend/migrations/0004_backuprun_snapshot.py b/src/pobsync_backend/migrations/0004_backuprun_snapshot.py new file mode 100644 index 0000000..e0d8e33 --- /dev/null +++ b/src/pobsync_backend/migrations/0004_backuprun_snapshot.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pobsync_backend", "0003_structured_config_fields"), + ] + + operations = [ + migrations.AddField( + model_name="backuprun", + name="snapshot", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="backup_runs", + to="pobsync_backend.snapshotrecord", + ), + ), + ] diff --git a/src/pobsync_backend/models.py b/src/pobsync_backend/models.py index ee65a11..4e4a70d 100644 --- a/src/pobsync_backend/models.py +++ b/src/pobsync_backend/models.py @@ -82,6 +82,13 @@ class BackupRun(models.Model): 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) diff --git a/src/pobsync_backend/tests/test_run_backup_records_snapshot.py b/src/pobsync_backend/tests/test_run_backup_records_snapshot.py index 7bb8da1..ad48e35 100644 --- a/src/pobsync_backend/tests/test_run_backup_records_snapshot.py +++ b/src/pobsync_backend/tests/test_run_backup_records_snapshot.py @@ -43,8 +43,10 @@ class RunBackupRecordsSnapshotTests(TestCase): call_command("run_pobsync_backup", host.host, prefix=str(Path(tmp) / "home"), stdout=StringIO()) self.assertEqual(BackupRun.objects.count(), 1) + run = BackupRun.objects.get() self.assertEqual(SnapshotRecord.objects.count(), 1) record = SnapshotRecord.objects.get() + self.assertEqual(run.snapshot, record) self.assertEqual(record.host, host) self.assertEqual(record.kind, "scheduled") self.assertEqual(record.status, "success") @@ -74,6 +76,7 @@ class RunBackupRecordsSnapshotTests(TestCase): run = BackupRun.objects.get() self.assertEqual(run.status, BackupRun.Status.FAILED) record = SnapshotRecord.objects.get() + self.assertEqual(run.snapshot, record) self.assertEqual(record.kind, "incomplete") self.assertEqual(record.status, "failed") @@ -99,4 +102,5 @@ class RunBackupRecordsSnapshotTests(TestCase): ) self.assertEqual(BackupRun.objects.count(), 1) + self.assertIsNone(BackupRun.objects.get().snapshot) self.assertEqual(SnapshotRecord.objects.count(), 0)