(ui) Add review actions to filtered run lists #32

Merged
parkel merged 1 commits from issue-22-operational-review-actions into master 2026-05-21 13:12:20 +02:00
4 changed files with 55 additions and 3 deletions
Showing only changes of commit fe4ae9d147 - Show all commits

View File

@@ -273,6 +273,11 @@
} }
button.secondary:hover, button.secondary:hover,
.button-link.secondary:hover { background: #eef3f8; } .button-link.secondary:hover { background: #eef3f8; }
button.compact,
.button-link.compact {
font-size: 12px;
padding: 5px 8px;
}
button:disabled { button:disabled {
background: #d8dee6; background: #d8dee6;
border-color: #d8dee6; border-color: #d8dee6;

View File

@@ -95,7 +95,22 @@
<span class="muted">none</span> <span class="muted">none</span>
{% endif %} {% endif %}
</td> </td>
<td>{% if run.reviewed_at %}reviewed{% elif run.status == "failed" or run.status == "warning" %}<span class="status warning">needed</span>{% else %}<span class="muted">none</span>{% endif %}</td> <td>
{% if run.reviewed_at %}
reviewed
{% elif run.status == "failed" or run.status == "warning" %}
<div class="stack">
<span class="status warning">needed</span>
<form class="inline-form" method="post" action="{% url 'resolve_run_review' run.id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="secondary compact">Mark reviewed</button>
</form>
</div>
{% else %}
<span class="muted">none</span>
{% endif %}
</td>
</tr> </tr>
{% empty %} {% empty %}
<tr><td colspan="9" class="muted">No runs matched the current filter.</td></tr> <tr><td colspan="9" class="muted">No runs matched the current filter.</td></tr>

View File

@@ -234,6 +234,31 @@ class ViewTests(TestCase):
self.assertContains(response, "needed") self.assertContains(response, "needed")
self.assertNotContains(response, f"Run {success.id}") self.assertNotContains(response, f"Run {success.id}")
def test_runs_list_can_mark_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, run_type=BackupRun.RunType.MANUAL)
list_url = f'{reverse("runs_list")}?status=failed&review=needed'
response = self.client.get(list_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Mark reviewed")
self.assertContains(response, 'value="/runs/?status=failed&amp;review=needed"', html=False)
response = self.client.post(
reverse("resolve_run_review", args=[run.id]),
{"next": list_url},
follow=True,
)
run.refresh_from_db()
self.assertIsNotNone(run.reviewed_at)
self.assertEqual(run.reviewed_by, self.staff_user.username)
self.assertRedirects(response, list_url)
self.assertContains(response, f"Run {run.id} marked reviewed.")
self.assertNotContains(response, f"Run {run.id}</a>", html=False)
def test_snapshots_list_filters_by_host_and_kind(self) -> None: def test_snapshots_list_filters_by_host_and_kind(self) -> None:
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
web = HostConfig.objects.create(host="web-01", address="web-01.example.test") web = HostConfig.objects.create(host="web-01", address="web-01.example.test")

View File

@@ -661,13 +661,13 @@ def resolve_run_review(request, run_id: int):
return redirect("run_detail", run_id=run.id) return redirect("run_detail", run_id=run.id)
if run.reviewed_at: if run.reviewed_at:
messages.info(request, f"Run {run.id} was already marked reviewed.") messages.info(request, f"Run {run.id} was already marked reviewed.")
return redirect("run_detail", run_id=run.id) return _redirect_after_run_review(request, run)
run.reviewed_at = timezone.now() run.reviewed_at = timezone.now()
run.reviewed_by = request.user.get_username() run.reviewed_by = request.user.get_username()
run.save(update_fields=["reviewed_at", "reviewed_by"]) run.save(update_fields=["reviewed_at", "reviewed_by"])
messages.success(request, f"Run {run.id} marked reviewed.") messages.success(request, f"Run {run.id} marked reviewed.")
return redirect("run_detail", run_id=run.id) return _redirect_after_run_review(request, run)
@staff_member_required @staff_member_required
@@ -937,6 +937,13 @@ def _next_run_for_schedule(schedule: ScheduleConfig | None, host_config: HostCon
return None return None
def _redirect_after_run_review(request, run: BackupRun):
next_url = request.POST.get("next", "").strip()
if next_url.startswith("/"):
return redirect(next_url)
return redirect("run_detail", run_id=run.id)
def _retention_warning_for_host(host_config: HostConfig, schedule: ScheduleConfig | None) -> dict[str, object]: def _retention_warning_for_host(host_config: HostConfig, schedule: ScheduleConfig | None) -> dict[str, object]:
incomplete_count = host_config.snapshots.filter( incomplete_count = host_config.snapshots.filter(
kind=SnapshotRecord.Kind.INCOMPLETE, kind=SnapshotRecord.Kind.INCOMPLETE,