From 123583a50241d11d0fb3d140ecfb056a563879bd Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Tue, 19 May 2026 12:00:19 +0200 Subject: [PATCH] (feature) Add read-only retention plan view Add a staff-only retention plan page for each host using the SQL-backed retention service. Link it from the host detail page and show policy settings, keep reasons, and snapshots that would be deleted for scheduled, manual, or all snapshot kinds. Keep the flow non-destructive for now, validate query parameters, and cover the view with tests for rendering, base protection, and invalid kind handling. --- README.md | 1 + .../templates/pobsync_backend/base.html | 4 +- .../pobsync_backend/host_detail.html | 1 + .../pobsync_backend/retention_plan.html | 83 +++++++++++++++++++ src/pobsync_backend/tests/test_views.py | 52 ++++++++++++ src/pobsync_backend/views.py | 25 ++++++ src/pobsync_server/urls.py | 1 + 7 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 src/pobsync_backend/templates/pobsync_backend/retention_plan.html diff --git a/README.md b/README.md index 65ba319..41d01c7 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ Post-backup pruning from Django also uses the SQL retention service after the co Staff-only JSON endpoints expose service status, hosts, snapshots, and backup runs for lightweight inspection. Staff-only dashboard views expose the same operational state through Django templates. Host pages include a safe snapshot discovery action that records existing snapshots into SQL. +Host pages also include a read-only SQL retention plan view before any destructive pruning action. The remaining internal engine code still contains reusable backup primitives: diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 6f9fd09..8a17752 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -80,7 +80,7 @@ .stack { display: grid; gap: 4px; } .two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } .actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; } - button { + button, .button-link { appearance: none; background: #17202a; border: 1px solid #17202a; @@ -91,7 +91,7 @@ font-weight: 650; padding: 8px 12px; } - button:hover { background: #2a394a; } + button:hover, .button-link:hover { background: #2a394a; text-decoration: none; } .messages { display: grid; gap: 8px; margin-bottom: 18px; } .message { background: var(--panel); diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html index 1c3e7dc..d4fd7bc 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -10,6 +10,7 @@ {% csrf_token %} + Plan retention
diff --git a/src/pobsync_backend/templates/pobsync_backend/retention_plan.html b/src/pobsync_backend/templates/pobsync_backend/retention_plan.html new file mode 100644 index 0000000..dac85a7 --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/retention_plan.html @@ -0,0 +1,83 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}Retention plan | {{ host.host }}{% endblock %} + +{% block content %} +

Retention Plan: {{ host.host }}

+ +
+ Back to host + Scheduled + Manual + All + Protect bases +
+ +
+
Source
{{ plan.source }}
+
Kind
{{ plan.kind }}
+
Keep
{{ plan.keep|length }}
+
Would Delete
{{ plan.delete|length }}
+
+ +
+

Policy

+
+
Daily: {{ plan.retention.daily }}
+
Weekly: {{ plan.retention.weekly }}
+
Monthly: {{ plan.retention.monthly }}
+
Yearly: {{ plan.retention.yearly }}
+
Protect bases: {{ protect_bases|yesno:"yes,no" }}
+
+
+ +
+

Would Delete

+ + + + + + + + + + + + {% for snapshot in plan.delete %} + + + + + + + + {% empty %} + + {% endfor %} + +
KindDirnameStartedStatusPath
{{ snapshot.kind }}{{ snapshot.dirname }}{{ snapshot.dt }}{{ snapshot.status|default:"" }}{{ snapshot.path }}
Retention would not delete snapshots for this selection.
+
+ +
+

Keep Reasons

+ + + + + + + + + {% for dirname, reasons in plan.reasons.items %} + + + + + {% empty %} + + {% endfor %} + +
DirnameReasons
{{ dirname }}{{ reasons|join:", " }}
No snapshots matched this retention selection.
+
+{% endblock %} diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index cfbc9fc..e34a0ed 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -100,6 +100,58 @@ class ViewTests(TestCase): self.assertEqual(response.status_code, 405) + def test_retention_plan_renders_keep_and_delete_sets(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create( + host="web-01", + address="web-01.example.test", + retention_daily=0, + retention_weekly=0, + retention_monthly=0, + retention_yearly=0, + ) + old_snapshot = self._snapshot(host, "20260518-021500Z__OLDSNAP") + new_snapshot = self._snapshot(host, "20260519-021500Z__NEWSNAP") + + response = self.client.get(reverse("host_retention_plan", args=[host.host])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Retention Plan: web-01") + self.assertContains(response, old_snapshot.dirname) + self.assertContains(response, new_snapshot.dirname) + self.assertContains(response, "newest") + self.assertContains(response, "Would Delete") + + def test_retention_plan_can_enable_base_protection(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create( + host="web-01", + address="web-01.example.test", + retention_daily=0, + retention_weekly=0, + retention_monthly=0, + retention_yearly=0, + ) + base = self._snapshot(host, "20260518-021500Z__BASESNAP") + child = self._snapshot(host, "20260519-021500Z__CHILDSNP") + child.base = base + child.save(update_fields=["base"]) + + response = self.client.get(reverse("host_retention_plan", args=[host.host]), {"protect_bases": "1"}) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Protect bases: yes") + self.assertContains(response, f"base-of:{child.dirname}") + + 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") + + response = self.client.get(reverse("host_retention_plan", args=[host.host]), {"kind": "weird"}, follow=True) + + self.assertRedirects(response, reverse("host_detail", args=[host.host])) + self.assertContains(response, "Retention kind must be scheduled, manual, or all.") + def _snapshot(self, host: HostConfig, dirname: str) -> SnapshotRecord: started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc) return SnapshotRecord.objects.create( diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index 58bc8d0..d04fb8c 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -6,7 +6,10 @@ from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.http import require_POST +from pobsync.errors import ConfigError + from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord +from .retention import run_sql_retention_plan from .snapshot_discovery import discover_snapshots @@ -70,6 +73,28 @@ def discover_host_snapshots(request, host: str): return redirect("host_detail", host=host_config.host) +@staff_member_required +def host_retention_plan(request, host: str): + host_config = get_object_or_404(HostConfig, host=host) + kind = request.GET.get("kind", "scheduled") + if kind not in {"scheduled", "manual", "all"}: + messages.error(request, "Retention kind must be scheduled, manual, or all.") + return redirect("host_detail", host=host_config.host) + protect_bases = request.GET.get("protect_bases") in {"1", "true", "on", "yes"} + try: + plan = run_sql_retention_plan(host=host_config.host, kind=kind, protect_bases=protect_bases) + except ConfigError as exc: + messages.error(request, str(exc)) + return redirect("host_detail", host=host_config.host) + context = { + "host": host_config, + "kind": kind, + "protect_bases": protect_bases, + "plan": plan, + } + return render(request, "pobsync_backend/retention_plan.html", context) + + def _schedule_for_host(host_config: HostConfig) -> ScheduleConfig | None: try: return host_config.schedule diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index e09470f..fbb6dbe 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -10,6 +10,7 @@ urlpatterns = [ path("", views.dashboard, name="dashboard"), path("hosts//", views.host_detail, name="host_detail"), path("hosts//discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"), + path("hosts//retention-plan/", views.host_retention_plan, name="host_retention_plan"), path("api/", api.api_index), path("api/status/", api.status), path("api/hosts/", api.hosts),