(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.
This commit is contained in:
2026-05-19 12:17:17 +02:00
parent 6d7bf531ac
commit 4dbde43465
8 changed files with 206 additions and 3 deletions

View File

@@ -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 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. 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. 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: The remaining internal engine code still contains reusable backup primitives:

View File

@@ -2,10 +2,68 @@ from __future__ import annotations
from django import forms from django import forms
from .models import ScheduleConfig from .models import HostConfig, ScheduleConfig
from .scheduler import parse_cron_expr 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): class ScheduleConfigForm(forms.ModelForm):
cron_expr = forms.CharField( cron_expr = forms.CharField(
label="Cron expression", label="Cron expression",

View File

@@ -104,13 +104,14 @@
.form-grid { display: grid; gap: 14px; max-width: 680px; } .form-grid { display: grid; gap: 14px; max-width: 680px; }
.field { display: grid; gap: 5px; } .field { display: grid; gap: 5px; }
.field label { font-weight: 650; } .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: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
font: inherit; font: inherit;
padding: 8px 10px; padding: 8px 10px;
width: 100%; width: 100%;
} }
.field textarea { min-height: 92px; resize: vertical; }
.field .helptext { color: var(--muted); font-size: 12px; } .field .helptext { color: var(--muted); font-size: 12px; }
.errorlist { .errorlist {
color: var(--failed); color: var(--failed);

View File

@@ -6,6 +6,7 @@
<h1>{{ host.host }}</h1> <h1>{{ host.host }}</h1>
<section class="actions" aria-label="Host actions"> <section class="actions" aria-label="Host actions">
<a class="button-link" href="{% url 'edit_host_config' host.host %}">Edit config</a>
<form method="post" action="{% url 'discover_host_snapshots' host.host %}"> <form method="post" action="{% url 'discover_host_snapshots' host.host %}">
{% csrf_token %} {% csrf_token %}
<button type="submit">Discover snapshots</button> <button type="submit">Discover snapshots</button>

View File

@@ -0,0 +1,32 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Config | {{ host.host }}{% endblock %}
{% block content %}
<h1>Config: {{ host.host }}</h1>
<section class="actions" aria-label="Config actions">
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
</section>
<section class="panel">
<h2>Edit Host Config</h2>
<form method="post" class="form-grid">
{% csrf_token %}
{{ form.non_field_errors }}
{% for field in form %}
<div class="field">
{{ field.errors }}
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
{{ field }}
{% if field.help_text %}<div class="helptext">{{ field.help_text }}</div>{% endif %}
</div>
{% endfor %}
<div class="actions">
<button type="submit">Save config</button>
</div>
</form>
</section>
{% endblock %}

View File

@@ -68,6 +68,7 @@ class ViewTests(TestCase):
self.assertContains(response, "20260519-021500Z__ABCDEFGH") self.assertContains(response, "20260519-021500Z__ABCDEFGH")
self.assertContains(response, "Discover snapshots") self.assertContains(response, "Discover snapshots")
self.assertContains(response, "Edit schedule") self.assertContains(response, "Edit schedule")
self.assertContains(response, "Edit config")
def test_host_detail_returns_404_for_unknown_host(self) -> None: def test_host_detail_returns_404_for_unknown_host(self) -> None:
self.client.force_login(self.staff_user) 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.assertContains(response, "cron expression must have exactly 5 fields")
self.assertFalse(ScheduleConfig.objects.filter(host=host).exists()) 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: 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) started_at = datetime.strptime(dirname.split("__", 1)[0], "%Y%m%d-%H%M%SZ").replace(tzinfo=timezone.utc)
return SnapshotRecord.objects.create( return SnapshotRecord.objects.create(

View File

@@ -8,7 +8,7 @@ from django.views.decorators.http import require_POST
from pobsync.errors import ConfigError from pobsync.errors import ConfigError
from .forms import ScheduleConfigForm from .forms import HostConfigForm, ScheduleConfigForm
from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
from .retention import run_sql_retention_plan from .retention import run_sql_retention_plan
from .snapshot_discovery import discover_snapshots 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) 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 @staff_member_required
def edit_host_schedule(request, host: str): def edit_host_schedule(request, host: str):
host_config = get_object_or_404(HostConfig, host=host) host_config = get_object_or_404(HostConfig, host=host)

View File

@@ -9,6 +9,7 @@ from pobsync_backend import api, views
urlpatterns = [ urlpatterns = [
path("", views.dashboard, name="dashboard"), path("", views.dashboard, name="dashboard"),
path("hosts/<str:host>/", views.host_detail, name="host_detail"), path("hosts/<str:host>/", views.host_detail, name="host_detail"),
path("hosts/<str:host>/config/", views.edit_host_config, name="edit_host_config"),
path("hosts/<str:host>/discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"), path("hosts/<str:host>/discover-snapshots/", views.discover_host_snapshots, name="discover_host_snapshots"),
path("hosts/<str:host>/retention-plan/", views.host_retention_plan, name="host_retention_plan"), path("hosts/<str:host>/retention-plan/", views.host_retention_plan, name="host_retention_plan"),
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"), path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),