2 Commits

Author SHA1 Message Date
4dbde43465 (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.
2026-05-19 12:17:17 +02:00
6d7bf531ac (feature) Add schedule editing view for hosts
Add a staff-only Django form for creating and updating host schedules using the
SQL-backed ScheduleConfig model. Link the form from host detail pages, validate
cron expressions with the existing scheduler parser, and preserve scheduler/CLI
behavior by writing to the same source of truth.

Cover default rendering, schedule creation, updates, and invalid cron handling
with view tests.
2026-05-19 12:13:12 +02:00
9 changed files with 403 additions and 0 deletions

View File

@@ -155,6 +155,8 @@ Staff-only JSON endpoints expose service status, hosts, snapshots, and backup ru
Staff-only dashboard views expose the same operational state through Django templates. 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 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.
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

@@ -0,0 +1,91 @@
from __future__ import annotations
from django import forms
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",
help_text='Five-field cron expression, for example "15 2 * * *".',
)
prune_max_delete = forms.IntegerField(min_value=0)
class Meta:
model = ScheduleConfig
fields = (
"cron_expr",
"user",
"enabled",
"prune",
"prune_max_delete",
"prune_protect_bases",
)
def clean_cron_expr(self) -> str:
cron_expr = self.cleaned_data["cron_expr"].strip()
try:
parse_cron_expr(cron_expr)
except ValueError as exc:
raise forms.ValidationError(str(exc)) from exc
return cron_expr

View File

@@ -101,6 +101,24 @@
} }
.message.success { border-color: #a7d8b9; background: #edf8f1; color: var(--success); } .message.success { border-color: #a7d8b9; background: #edf8f1; color: var(--success); }
.message.error { border-color: #e8b4b4; background: #fff0f0; color: var(--failed); } .message.error { border-color: #e8b4b4; background: #fff0f0; color: var(--failed); }
.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 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);
list-style: none;
margin: 0;
padding: 0;
}
@media (max-width: 800px) { @media (max-width: 800px) {
main { padding: 16px; } main { padding: 16px; }
nav { padding: 0; } nav { padding: 0; }

View File

@@ -6,11 +6,13 @@
<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>
</form> </form>
<a class="button-link" href="{% url 'host_retention_plan' host.host %}">Plan retention</a> <a class="button-link" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
<a class="button-link" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a>
</section> </section>
<section class="grid" aria-label="Host summary"> <section class="grid" aria-label="Host summary">

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

@@ -0,0 +1,32 @@
{% extends "pobsync_backend/base.html" %}
{% block title %}Schedule | {{ host.host }}{% endblock %}
{% block content %}
<h1>Schedule: {{ host.host }}</h1>
<section class="actions" aria-label="Schedule actions">
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
</section>
<section class="panel">
<h2>{% if schedule %}Edit Schedule{% else %}Create Schedule{% endif %}</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 schedule</button>
</div>
</form>
</section>
{% endblock %}

View File

@@ -67,6 +67,8 @@ class ViewTests(TestCase):
self.assertContains(response, "15 2 * * *") self.assertContains(response, "15 2 * * *")
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 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)
@@ -152,6 +154,170 @@ class ViewTests(TestCase):
self.assertRedirects(response, reverse("host_detail", args=[host.host])) self.assertRedirects(response, reverse("host_detail", args=[host.host]))
self.assertContains(response, "Retention kind must be scheduled, manual, or all.") self.assertContains(response, "Retention kind must be scheduled, manual, or all.")
def test_schedule_form_renders_defaults_for_new_schedule(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("edit_host_schedule", args=[host.host]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Create Schedule")
self.assertContains(response, "15 2 * * *")
self.assertContains(response, "Save schedule")
def test_schedule_form_creates_schedule(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_schedule", args=[host.host]),
{
"cron_expr": "30 3 * * *",
"user": "root",
"enabled": "on",
"prune": "on",
"prune_max_delete": "4",
"prune_protect_bases": "on",
},
follow=True,
)
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
self.assertContains(response, "Schedule saved for web-01.")
schedule = ScheduleConfig.objects.get(host=host)
self.assertEqual(schedule.cron_expr, "30 3 * * *")
self.assertTrue(schedule.enabled)
self.assertTrue(schedule.prune)
self.assertEqual(schedule.prune_max_delete, 4)
self.assertTrue(schedule.prune_protect_bases)
def test_schedule_form_updates_existing_schedule(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
schedule = ScheduleConfig.objects.create(host=host, cron_expr="15 2 * * *", enabled=True, prune=True)
response = self.client.post(
reverse("edit_host_schedule", args=[host.host]),
{
"cron_expr": "45 4 * * 1",
"user": "backup",
"prune_max_delete": "8",
},
follow=True,
)
self.assertRedirects(response, reverse("host_detail", args=[host.host]))
schedule.refresh_from_db()
self.assertEqual(schedule.cron_expr, "45 4 * * 1")
self.assertEqual(schedule.user, "backup")
self.assertFalse(schedule.enabled)
self.assertFalse(schedule.prune)
self.assertEqual(schedule.prune_max_delete, 8)
def test_schedule_form_rejects_invalid_cron(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_schedule", args=[host.host]),
{
"cron_expr": "bad cron",
"user": "root",
"enabled": "on",
"prune_max_delete": "10",
},
)
self.assertEqual(response.status_code, 200)
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: 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,6 +8,7 @@ from django.views.decorators.http import require_POST
from pobsync.errors import ConfigError from pobsync.errors import ConfigError
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
@@ -95,8 +96,65 @@ 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
def edit_host_schedule(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
schedule = _schedule_for_host(host_config)
if request.method == "POST":
form = ScheduleConfigForm(request.POST, instance=schedule)
if form.is_valid():
saved_schedule = form.save(commit=False)
saved_schedule.host = host_config
saved_schedule.save()
messages.success(request, f"Schedule saved for {host_config.host}.")
return redirect("host_detail", host=host_config.host)
else:
form = ScheduleConfigForm(instance=schedule, initial=_default_schedule_initial())
return render(
request,
"pobsync_backend/schedule_form.html",
{
"host": host_config,
"schedule": schedule,
"form": form,
},
)
def _schedule_for_host(host_config: HostConfig) -> ScheduleConfig | None: def _schedule_for_host(host_config: HostConfig) -> ScheduleConfig | None:
try: try:
return host_config.schedule return host_config.schedule
except ScheduleConfig.DoesNotExist: except ScheduleConfig.DoesNotExist:
return None return None
def _default_schedule_initial() -> dict[str, object]:
return {
"cron_expr": "15 2 * * *",
"user": "root",
"enabled": True,
"prune_max_delete": 10,
}

View File

@@ -9,8 +9,10 @@ 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("api/", api.api_index), path("api/", api.api_index),
path("api/status/", api.status), path("api/status/", api.status),
path("api/hosts/", api.hosts), path("api/hosts/", api.hosts),