(release) Add review resolution for operational tasks

Add reviewed state for failed/warning runs and incomplete snapshot records,
then use it to clear dashboard and host “need review” tasks after an operator
has acknowledged them.

Expose Mark reviewed actions on run detail and host retention warnings, keep
reviewed records available for audit/debug, and exclude reviewed problem runs
from operational counts and latest issue summaries.

Refs #19
Refs #8
This commit is contained in:
2026-05-21 03:34:41 +02:00
parent d0c23deb72
commit c2e5a534aa
9 changed files with 222 additions and 9 deletions

View File

@@ -186,7 +186,7 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Operational Status")
self.assertContains(response, "No queued, running, warning, or failed runs.")
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.")
def test_dashboard_surfaces_retention_warnings(self) -> None:
self.client.force_login(self.staff_user)
@@ -216,6 +216,30 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Scheduled prune would delete 2 snapshot(s), above max 1.")
self.assertContains(response, "1 incomplete snapshot(s) need review.")
self.assertContains(response, "Mark reviewed")
def test_dashboard_ignores_reviewed_problem_runs(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
BackupRun.objects.create(
host=host,
status=BackupRun.Status.FAILED,
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
reviewed_by="admin",
)
BackupRun.objects.create(
host=host,
status=BackupRun.Status.WARNING,
reviewed_at=datetime(2026, 5, 19, 4, 20, tzinfo=timezone.utc),
reviewed_by="admin",
)
response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "No queued, running, or unreviewed warning/failed runs.")
self.assertNotContains(response, "failed 1")
self.assertNotContains(response, "warning 1")
def test_dashboard_links_latest_snapshot_for_each_host(self) -> None:
self.client.force_login(self.staff_user)
@@ -1310,6 +1334,34 @@ class ViewTests(TestCase):
self.assertContains(response, "Incomplete ignored")
self.assertContains(response, "deleted scheduled 20260518-021500Z__OLD")
def test_run_review_action_marks_problem_run_reviewed(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
run = BackupRun.objects.create(host=host, status=BackupRun.Status.FAILED, result={"ok": False})
response = self.client.post(reverse("resolve_run_review", args=[run.id]), follow=True)
run.refresh_from_db()
self.assertIsNotNone(run.reviewed_at)
self.assertEqual(run.reviewed_by, self.staff_user.username)
self.assertRedirects(response, reverse("run_detail", args=[run.id]))
self.assertContains(response, f"Run {run.id} marked reviewed.")
self.assertContains(response, "Review")
self.assertContains(response, self.staff_user.username)
self.assertNotContains(response, "Mark reviewed")
def test_run_review_action_ignores_successful_run(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, result={"ok": True})
response = self.client.post(reverse("resolve_run_review", args=[run.id]), follow=True)
run.refresh_from_db()
self.assertIsNone(run.reviewed_at)
self.assertRedirects(response, reverse("run_detail", args=[run.id]))
self.assertContains(response, f"Run {run.id} does not need review.")
def test_run_detail_surfaces_host_retention_warnings(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(
@@ -1704,6 +1756,46 @@ class ViewTests(TestCase):
self.assertContains(response, "Retention Warnings")
self.assertContains(response, "Scheduled pruning would delete 2 snapshot(s), above max delete")
def test_host_detail_can_mark_incomplete_snapshots_reviewed(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
incomplete = SnapshotRecord.objects.create(
host=host,
kind=SnapshotRecord.Kind.INCOMPLETE,
dirname="20260519-031500Z__BROKEN01",
path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
status="failed",
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
)
response = self.client.post(reverse("resolve_host_incomplete_reviews", args=[host.host]), follow=True)
incomplete.refresh_from_db()
self.assertIsNotNone(incomplete.reviewed_at)
self.assertEqual(incomplete.reviewed_by, self.staff_user.username)
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
self.assertContains(response, "Marked 1 incomplete snapshot(s) reviewed for web-01.")
self.assertNotContains(response, "Retention Warnings")
def test_host_detail_does_not_warn_for_reviewed_incomplete_snapshots(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
SnapshotRecord.objects.create(
host=host,
kind=SnapshotRecord.Kind.INCOMPLETE,
dirname="20260519-031500Z__BROKEN01",
path=f"/backups/{host.host}/.incomplete/20260519-031500Z__BROKEN01",
status="failed",
started_at=datetime(2026, 5, 19, 3, 15, tzinfo=timezone.utc),
reviewed_at=datetime(2026, 5, 19, 4, 15, tzinfo=timezone.utc),
reviewed_by="admin",
)
response = self.client.get(reverse("host_detail", args=[host.host]))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "Retention Warnings")
def test_retention_plan_rejects_invalid_kind(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")