(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

@@ -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",

View File

@@ -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);

View File

@@ -6,6 +6,7 @@
<h1>{{ host.host }}</h1>
<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 %}">
{% csrf_token %}
<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, "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(

View File

@@ -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)

View File

@@ -9,6 +9,7 @@ from pobsync_backend import api, views
urlpatterns = [
path("", views.dashboard, name="dashboard"),
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>/retention-plan/", views.host_retention_plan, name="host_retention_plan"),
path("hosts/<str:host>/schedule/", views.edit_host_schedule, name="edit_host_schedule"),