2 Commits

Author SHA1 Message Date
0fe2aa439f Merge pull request '(ui) Add review actions to filtered run lists' (#32) from issue-22-operational-review-actions into master
Reviewed-on: #32
2026-05-21 13:12:20 +02:00
fe4ae9d147 (ui) Add review actions to filtered run lists
Add inline Mark reviewed actions for failed and warning runs on the run list,
preserving active filters after review so Operational Status drill-downs can
be cleared without opening every run detail page.

Refs #22
2026-05-21 13:07:45 +02:00
4 changed files with 55 additions and 3 deletions

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,