From 4dbde4346522b1600a392a763d447274af673ff6 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Tue, 19 May 2026 12:17:17 +0200 Subject: [PATCH] (feature) Add host config editing view Add a staff-only Django form for editing operational host settings while keeping host identity stable. Support address, enablement, SSH/source overrides, include/exclude lists, rsync extra args, and retention settings using the same SQL-backed HostConfig model consumed by backup and scheduler flows. Parse newline-separated list fields into JSON lists, preserve nullable excludes_replace semantics, and cover rendering plus update behavior with view tests. --- README.md | 1 + src/pobsync_backend/forms.py | 60 ++++++++++++- .../templates/pobsync_backend/base.html | 3 +- .../pobsync_backend/host_detail.html | 1 + .../templates/pobsync_backend/host_form.html | 32 +++++++ src/pobsync_backend/tests/test_views.py | 87 +++++++++++++++++++ src/pobsync_backend/views.py | 24 ++++- src/pobsync_server/urls.py | 1 + 8 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 src/pobsync_backend/templates/pobsync_backend/host_form.html diff --git a/README.md b/README.md index a0637ff..e63ec67 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ Staff-only dashboard views expose the same operational state through Django temp 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. Schedules can be created or updated from host pages using the same SQL-backed scheduler model. +Host config can be edited from host pages while keeping host identity stable. The remaining internal engine code still contains reusable backup primitives: diff --git a/src/pobsync_backend/forms.py b/src/pobsync_backend/forms.py index 20ca033..f2f33ad 100644 --- a/src/pobsync_backend/forms.py +++ b/src/pobsync_backend/forms.py @@ -2,10 +2,68 @@ from __future__ import annotations from django import forms -from .models import ScheduleConfig +from .models import HostConfig, ScheduleConfig from .scheduler import parse_cron_expr +class NewlineListField(forms.CharField): + widget = forms.Textarea + + def __init__(self, *args, **kwargs) -> None: + kwargs.setdefault("required", False) + super().__init__(*args, **kwargs) + + def prepare_value(self, value): + if isinstance(value, list): + return "\n".join(str(item) for item in value) + return value + + def to_python(self, value) -> list[str]: + if not value: + return [] + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + return [line.strip() for line in str(value).splitlines() if line.strip()] + + +class NullableNewlineListField(NewlineListField): + def to_python(self, value) -> list[str] | None: + parsed = super().to_python(value) + return parsed or None + + +class HostConfigForm(forms.ModelForm): + includes = NewlineListField(help_text="One include path per line. Leave empty to include defaults.") + excludes_add = NewlineListField(help_text="One additional exclude pattern per line.") + excludes_replace = NullableNewlineListField( + help_text="Optional. When set, replaces global excludes; one pattern per line." + ) + rsync_extra_args = NewlineListField(help_text="One extra rsync argument per line.") + + class Meta: + model = HostConfig + fields = ( + "address", + "enabled", + "ssh_user", + "ssh_port", + "source_root", + "includes", + "excludes_add", + "excludes_replace", + "rsync_extra_args", + "retention_daily", + "retention_weekly", + "retention_monthly", + "retention_yearly", + ) + help_texts = { + "ssh_user": "Leave empty to use the global SSH user.", + "ssh_port": "Leave empty to use the global SSH port.", + "source_root": "Leave empty to use the global default source root.", + } + + class ScheduleConfigForm(forms.ModelForm): cron_expr = forms.CharField( label="Cron expression", diff --git a/src/pobsync_backend/templates/pobsync_backend/base.html b/src/pobsync_backend/templates/pobsync_backend/base.html index 5667901..ad73b00 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -104,13 +104,14 @@ .form-grid { display: grid; gap: 14px; max-width: 680px; } .field { display: grid; gap: 5px; } .field label { font-weight: 650; } - .field input[type="text"], .field input[type="number"] { + .field input[type="text"], .field input[type="number"], .field textarea { border: 1px solid var(--border); border-radius: 6px; font: inherit; padding: 8px 10px; width: 100%; } + .field textarea { min-height: 92px; resize: vertical; } .field .helptext { color: var(--muted); font-size: 12px; } .errorlist { color: var(--failed); diff --git a/src/pobsync_backend/templates/pobsync_backend/host_detail.html b/src/pobsync_backend/templates/pobsync_backend/host_detail.html index c929c7e..14a9c1c 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_detail.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_detail.html @@ -6,6 +6,7 @@

{{ host.host }}

+ Edit config
{% csrf_token %} diff --git a/src/pobsync_backend/templates/pobsync_backend/host_form.html b/src/pobsync_backend/templates/pobsync_backend/host_form.html new file mode 100644 index 0000000..1e7b004 --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/host_form.html @@ -0,0 +1,32 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}Config | {{ host.host }}{% endblock %} + +{% block content %} +

Config: {{ host.host }}

+ +
+ Back to host +
+ +
+

Edit Host Config

+ + {% csrf_token %} + {{ form.non_field_errors }} + + {% for field in form %} +
+ {{ field.errors }} + + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} +
+ {% endfor %} + +
+ +
+ +
+{% endblock %} diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 46d71a7..3f5b7a6 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -68,6 +68,7 @@ class ViewTests(TestCase): self.assertContains(response, "20260519-021500Z__ABCDEFGH") self.assertContains(response, "Discover snapshots") self.assertContains(response, "Edit schedule") + self.assertContains(response, "Edit config") def test_host_detail_returns_404_for_unknown_host(self) -> None: self.client.force_login(self.staff_user) @@ -231,6 +232,92 @@ class ViewTests(TestCase): self.assertContains(response, "cron expression must have exactly 5 fields") self.assertFalse(ScheduleConfig.objects.filter(host=host).exists()) + def test_host_config_form_renders_existing_values(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create( + host="web-01", + address="web-01.example.test", + includes=["/srv"], + excludes_add=["*.tmp"], + rsync_extra_args=["--numeric-ids"], + ) + + response = self.client.get(reverse("edit_host_config", args=[host.host])) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Config: web-01") + self.assertContains(response, "web-01.example.test") + self.assertContains(response, "/srv") + self.assertContains(response, "*.tmp") + self.assertContains(response, "--numeric-ids") + + def test_host_config_form_updates_host_config(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="old.example.test") + + response = self.client.post( + reverse("edit_host_config", args=[host.host]), + { + "address": "new.example.test", + "enabled": "on", + "ssh_user": "backup", + "ssh_port": "2222", + "source_root": "/srv", + "includes": "/srv/www\n/srv/db", + "excludes_add": "*.tmp\ncache/", + "excludes_replace": "", + "rsync_extra_args": "--numeric-ids\n--delete", + "retention_daily": "7", + "retention_weekly": "4", + "retention_monthly": "2", + "retention_yearly": "1", + }, + follow=True, + ) + + self.assertRedirects(response, reverse("host_detail", args=[host.host])) + self.assertContains(response, "Host config saved for web-01.") + host.refresh_from_db() + self.assertEqual(host.address, "new.example.test") + self.assertEqual(host.ssh_user, "backup") + self.assertEqual(host.ssh_port, 2222) + self.assertEqual(host.source_root, "/srv") + self.assertEqual(host.includes, ["/srv/www", "/srv/db"]) + self.assertEqual(host.excludes_add, ["*.tmp", "cache/"]) + self.assertIsNone(host.excludes_replace) + self.assertEqual(host.rsync_extra_args, ["--numeric-ids", "--delete"]) + self.assertEqual(host.retention_daily, 7) + self.assertEqual(host.retention_yearly, 1) + + def test_host_config_form_can_replace_global_excludes(self) -> None: + self.client.force_login(self.staff_user) + host = HostConfig.objects.create(host="web-01", address="web-01.example.test") + + response = self.client.post( + reverse("edit_host_config", args=[host.host]), + { + "address": host.address, + "ssh_user": "", + "ssh_port": "", + "source_root": "", + "includes": "", + "excludes_add": "", + "excludes_replace": "*.cache\nnode_modules/", + "rsync_extra_args": "", + "retention_daily": "14", + "retention_weekly": "8", + "retention_monthly": "12", + "retention_yearly": "0", + }, + follow=True, + ) + + self.assertRedirects(response, reverse("host_detail", args=[host.host])) + host.refresh_from_db() + self.assertFalse(host.enabled) + self.assertEqual(host.excludes_add, []) + self.assertEqual(host.excludes_replace, ["*.cache", "node_modules/"]) + 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 22474d6..c8e7fa3 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -8,7 +8,7 @@ from django.views.decorators.http import require_POST from pobsync.errors import ConfigError -from .forms import ScheduleConfigForm +from .forms import HostConfigForm, ScheduleConfigForm from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord from .retention import run_sql_retention_plan from .snapshot_discovery import discover_snapshots @@ -96,6 +96,28 @@ def host_retention_plan(request, host: str): return render(request, "pobsync_backend/retention_plan.html", context) +@staff_member_required +def edit_host_config(request, host: str): + host_config = get_object_or_404(HostConfig, host=host) + if request.method == "POST": + form = HostConfigForm(request.POST, instance=host_config) + if form.is_valid(): + form.save() + messages.success(request, f"Host config saved for {host_config.host}.") + return redirect("host_detail", host=host_config.host) + else: + form = HostConfigForm(instance=host_config) + + return render( + request, + "pobsync_backend/host_form.html", + { + "host": host_config, + "form": form, + }, + ) + + @staff_member_required def edit_host_schedule(request, host: str): host_config = get_object_or_404(HostConfig, host=host) diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index 6684688..15f28a1 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -9,6 +9,7 @@ from pobsync_backend import api, views urlpatterns = [ path("", views.dashboard, name="dashboard"), path("hosts//", views.host_detail, name="host_detail"), + path("hosts//config/", views.edit_host_config, name="edit_host_config"), 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("hosts//schedule/", views.edit_host_schedule, name="edit_host_schedule"),