From 6bcc15c174bbadefa033a2fe471251a187c01ad4 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Tue, 19 May 2026 12:25:45 +0200 Subject: [PATCH] (feature) add Django setup flow for initial pobsync configuration Add staff-only UI routes for creating/editing the default GlobalConfig and creating the first HostConfig from the dashboard. Improve the empty dashboard state so a fresh database guides the user towards the next useful setup action instead of only showing empty tables. Cover the setup flow with view tests for empty state prompts, global config creation, and host creation. --- src/pobsync_backend/forms.py | 48 +++++++++- .../templates/pobsync_backend/base.html | 8 ++ .../templates/pobsync_backend/dashboard.html | 23 +++++ .../pobsync_backend/global_form.html | 32 +++++++ .../templates/pobsync_backend/host_form.html | 14 ++- src/pobsync_backend/tests/test_views.py | 96 +++++++++++++++++++ src/pobsync_backend/views.py | 75 ++++++++++++++- src/pobsync_server/urls.py | 2 + 8 files changed, 290 insertions(+), 8 deletions(-) create mode 100644 src/pobsync_backend/templates/pobsync_backend/global_form.html diff --git a/src/pobsync_backend/forms.py b/src/pobsync_backend/forms.py index f2f33ad..3254dd3 100644 --- a/src/pobsync_backend/forms.py +++ b/src/pobsync_backend/forms.py @@ -2,7 +2,7 @@ from __future__ import annotations from django import forms -from .models import HostConfig, ScheduleConfig +from .models import GlobalConfig, HostConfig, ScheduleConfig from .scheduler import parse_cron_expr @@ -64,6 +64,52 @@ class HostConfigForm(forms.ModelForm): } +class CreateHostConfigForm(HostConfigForm): + class Meta(HostConfigForm.Meta): + fields = ("host", *HostConfigForm.Meta.fields) + help_texts = { + **HostConfigForm.Meta.help_texts, + "host": "Stable internal host name used for backup paths.", + } + + +class GlobalConfigForm(forms.ModelForm): + ssh_options = NewlineListField(help_text="One SSH option per line.") + rsync_args = NewlineListField(help_text="One default rsync argument per line.") + rsync_extra_args = NewlineListField(help_text="One extra rsync argument per line.") + excludes_default = NewlineListField(help_text="One default exclude pattern per line.") + + class Meta: + model = GlobalConfig + fields = ( + "name", + "backup_root", + "pobsync_home", + "ssh_user", + "ssh_port", + "ssh_options", + "rsync_binary", + "rsync_args", + "rsync_extra_args", + "rsync_timeout_seconds", + "rsync_bwlimit_kbps", + "default_source_root", + "default_destination_subdir", + "excludes_default", + "retention_daily", + "retention_weekly", + "retention_monthly", + "retention_yearly", + ) + help_texts = { + "name": "Usually 'default'. The backup engine currently reads the default config.", + "backup_root": "Directory that contains host backup folders.", + "pobsync_home": "Base directory for runtime state inside the container or host.", + "default_source_root": "Used by hosts without a custom source root.", + "default_destination_subdir": "Optional subdirectory below each snapshot.", + } + + 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 ad73b00..5db2f87 100644 --- a/src/pobsync_backend/templates/pobsync_backend/base.html +++ b/src/pobsync_backend/templates/pobsync_backend/base.html @@ -80,6 +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; } + .actions.inline { margin: 12px 0 0; } button, .button-link { appearance: none; background: #17202a; @@ -92,6 +93,12 @@ padding: 8px 12px; } button:hover, .button-link:hover { background: #2a394a; text-decoration: none; } + .button-link.secondary { + background: #fff; + border-color: var(--border); + color: var(--text); + } + .button-link.secondary:hover { background: #eef3f8; } .messages { display: grid; gap: 8px; margin-bottom: 18px; } .message { background: var(--panel); @@ -113,6 +120,7 @@ } .field textarea { min-height: 92px; resize: vertical; } .field .helptext { color: var(--muted); font-size: 12px; } + .field input[type="checkbox"] { justify-self: start; } .errorlist { color: var(--failed); list-style: none; diff --git a/src/pobsync_backend/templates/pobsync_backend/dashboard.html b/src/pobsync_backend/templates/pobsync_backend/dashboard.html index 79bd17b..ce0cbb9 100644 --- a/src/pobsync_backend/templates/pobsync_backend/dashboard.html +++ b/src/pobsync_backend/templates/pobsync_backend/dashboard.html @@ -5,7 +5,30 @@ {% block content %}

Dashboard

+
+ New host + {% if global_config %}Edit global config{% else %}Create global config{% endif %} +
+ + {% if not global_config or not counts.hosts %} +
+

Setup

+ {% if not global_config %} +

No default global config exists yet. Create it first so discovery, backups, and retention know where pobsync stores snapshots.

+ + {% elif not counts.hosts %} +

Global config is ready. Add the first host to make this dashboard useful.

+ + {% endif %} +
+ {% endif %} +
+
Global Configs
{{ counts.global_configs }}
Hosts
{{ counts.enabled_hosts }}/{{ counts.hosts }}
Schedules
{{ counts.enabled_schedules }}/{{ counts.schedules }}
Snapshots
{{ counts.snapshots }}
diff --git a/src/pobsync_backend/templates/pobsync_backend/global_form.html b/src/pobsync_backend/templates/pobsync_backend/global_form.html new file mode 100644 index 0000000..18a9e99 --- /dev/null +++ b/src/pobsync_backend/templates/pobsync_backend/global_form.html @@ -0,0 +1,32 @@ +{% extends "pobsync_backend/base.html" %} + +{% block title %}Global Config{% endblock %} + +{% block content %} +

{% if global_config %}Global Config{% else %}Create Global Config{% endif %}

+ +
+ Back to dashboard +
+ +
+

{% if global_config %}Edit Global Config{% else %}Create Global Config{% endif %}

+
+ {% 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/templates/pobsync_backend/host_form.html b/src/pobsync_backend/templates/pobsync_backend/host_form.html index 1e7b004..c316b88 100644 --- a/src/pobsync_backend/templates/pobsync_backend/host_form.html +++ b/src/pobsync_backend/templates/pobsync_backend/host_form.html @@ -1,16 +1,20 @@ {% extends "pobsync_backend/base.html" %} -{% block title %}Config | {{ host.host }}{% endblock %} +{% block title %}{% if host %}Config | {{ host.host }}{% else %}New Host{% endif %}{% endblock %} {% block content %} -

Config: {{ host.host }}

+

{% if host %}Config: {{ host.host }}{% else %}New Host{% endif %}

- Back to host + {% if host %} + Back to host + {% else %} + Back to dashboard + {% endif %}
-

Edit Host Config

+

{% if host %}Edit Host Config{% else %}Create Host Config{% endif %}

{% csrf_token %} {{ form.non_field_errors }} @@ -25,7 +29,7 @@ {% endfor %}
- +
diff --git a/src/pobsync_backend/tests/test_views.py b/src/pobsync_backend/tests/test_views.py index 3f5b7a6..0346d73 100644 --- a/src/pobsync_backend/tests/test_views.py +++ b/src/pobsync_backend/tests/test_views.py @@ -47,6 +47,102 @@ class ViewTests(TestCase): self.assertContains(response, "20260519-021500Z__ABCDEFGH") self.assertContains(response, "success") + def test_dashboard_prompts_for_global_config_when_database_is_empty(self) -> None: + self.client.force_login(self.staff_user) + + response = self.client.get(reverse("dashboard")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "No default global config exists yet.") + self.assertContains(response, reverse("edit_global_config")) + self.assertContains(response, "Create global config") + + def test_dashboard_prompts_for_first_host_after_global_config_exists(self) -> None: + self.client.force_login(self.staff_user) + GlobalConfig.objects.create(name="default", backup_root="/opt/pobsync/backups") + + response = self.client.get(reverse("dashboard")) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Global config is ready.") + self.assertContains(response, reverse("create_host_config")) + self.assertContains(response, "Add first host") + + def test_global_config_form_creates_default_config(self) -> None: + self.client.force_login(self.staff_user) + + response = self.client.post( + reverse("edit_global_config"), + { + "name": "default", + "backup_root": "/backups", + "pobsync_home": "/opt/pobsync", + "ssh_user": "backup", + "ssh_port": "2222", + "ssh_options": "StrictHostKeyChecking=no\nBatchMode=yes", + "rsync_binary": "rsync", + "rsync_args": "-a\n--numeric-ids", + "rsync_extra_args": "--delete", + "rsync_timeout_seconds": "60", + "rsync_bwlimit_kbps": "1000", + "default_source_root": "/srv", + "default_destination_subdir": "rootfs", + "excludes_default": "*.tmp\ncache/", + "retention_daily": "7", + "retention_weekly": "4", + "retention_monthly": "2", + "retention_yearly": "1", + }, + follow=True, + ) + + self.assertRedirects(response, reverse("dashboard")) + self.assertContains(response, "Global config saved for default.") + config = GlobalConfig.objects.get(name="default") + self.assertEqual(config.backup_root, "/backups") + self.assertEqual(config.ssh_user, "backup") + self.assertEqual(config.ssh_port, 2222) + self.assertEqual(config.ssh_options, ["StrictHostKeyChecking=no", "BatchMode=yes"]) + self.assertEqual(config.rsync_args, ["-a", "--numeric-ids"]) + self.assertEqual(config.rsync_extra_args, ["--delete"]) + self.assertEqual(config.excludes_default, ["*.tmp", "cache/"]) + self.assertEqual(config.retention_daily, 7) + self.assertEqual(config.retention_yearly, 1) + + def test_create_host_config_form_creates_host(self) -> None: + self.client.force_login(self.staff_user) + + response = self.client.post( + reverse("create_host_config"), + { + "host": "web-01", + "address": "web-01.example.test", + "enabled": "on", + "ssh_user": "backup", + "ssh_port": "2222", + "source_root": "/srv", + "includes": "/srv/www\n/srv/db", + "excludes_add": "*.tmp", + "excludes_replace": "", + "rsync_extra_args": "--numeric-ids", + "retention_daily": "7", + "retention_weekly": "4", + "retention_monthly": "2", + "retention_yearly": "1", + }, + follow=True, + ) + + self.assertRedirects(response, reverse("host_detail", args=["web-01"])) + self.assertContains(response, "Host config created for web-01.") + host = HostConfig.objects.get(host="web-01") + self.assertEqual(host.address, "web-01.example.test") + self.assertEqual(host.ssh_user, "backup") + self.assertEqual(host.includes, ["/srv/www", "/srv/db"]) + self.assertEqual(host.excludes_add, ["*.tmp"]) + self.assertEqual(host.rsync_extra_args, ["--numeric-ids"]) + self.assertEqual(host.retention_weekly, 4) + def test_host_detail_renders_config_schedule_runs_and_snapshots(self) -> None: self.client.force_login(self.staff_user) host = HostConfig.objects.create( diff --git a/src/pobsync_backend/views.py b/src/pobsync_backend/views.py index c8e7fa3..64925a3 100644 --- a/src/pobsync_backend/views.py +++ b/src/pobsync_backend/views.py @@ -8,8 +8,8 @@ from django.views.decorators.http import require_POST from pobsync.errors import ConfigError -from .forms import HostConfigForm, ScheduleConfigForm -from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord +from .forms import CreateHostConfigForm, GlobalConfigForm, HostConfigForm, ScheduleConfigForm +from .models import BackupRun, GlobalConfig, HostConfig, ScheduleConfig, SnapshotRecord from .retention import run_sql_retention_plan from .snapshot_discovery import discover_snapshots @@ -22,8 +22,10 @@ def dashboard(request): ) context = { "hosts": host_qs, + "global_config": GlobalConfig.objects.filter(name="default").first(), "latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10], "counts": { + "global_configs": GlobalConfig.objects.count(), "hosts": HostConfig.objects.count(), "enabled_hosts": HostConfig.objects.filter(enabled=True).count(), "schedules": ScheduleConfig.objects.count(), @@ -37,6 +39,49 @@ def dashboard(request): return render(request, "pobsync_backend/dashboard.html", context) +@staff_member_required +def edit_global_config(request): + global_config = GlobalConfig.objects.filter(name="default").first() + if request.method == "POST": + form = GlobalConfigForm(request.POST, instance=global_config) + if form.is_valid(): + saved_config = form.save() + messages.success(request, f"Global config saved for {saved_config.name}.") + return redirect("dashboard") + else: + form = GlobalConfigForm(instance=global_config, initial=_default_global_initial()) + + return render( + request, + "pobsync_backend/global_form.html", + { + "global_config": global_config, + "form": form, + }, + ) + + +@staff_member_required +def create_host_config(request): + if request.method == "POST": + form = CreateHostConfigForm(request.POST) + if form.is_valid(): + host_config = form.save() + messages.success(request, f"Host config created for {host_config.host}.") + return redirect("host_detail", host=host_config.host) + else: + form = CreateHostConfigForm(initial=_default_host_initial()) + + return render( + request, + "pobsync_backend/host_form.html", + { + "host": None, + "form": form, + }, + ) + + @staff_member_required def host_detail(request, host: str): host_config = get_object_or_404(HostConfig, host=host) @@ -158,3 +203,29 @@ def _default_schedule_initial() -> dict[str, object]: "enabled": True, "prune_max_delete": 10, } + + +def _default_global_initial() -> dict[str, object]: + return { + "name": "default", + "backup_root": "/opt/pobsync/backups", + "pobsync_home": "/opt/pobsync", + "ssh_user": "root", + "ssh_port": 22, + "rsync_binary": "rsync", + "default_source_root": "/", + "retention_daily": 14, + "retention_weekly": 8, + "retention_monthly": 12, + "retention_yearly": 0, + } + + +def _default_host_initial() -> dict[str, object]: + return { + "enabled": True, + "retention_daily": 14, + "retention_weekly": 8, + "retention_monthly": 12, + "retention_yearly": 0, + } diff --git a/src/pobsync_server/urls.py b/src/pobsync_server/urls.py index 15f28a1..c4fe56f 100644 --- a/src/pobsync_server/urls.py +++ b/src/pobsync_server/urls.py @@ -8,6 +8,8 @@ from pobsync_backend import api, views urlpatterns = [ path("", views.dashboard, name="dashboard"), + path("config/global/", views.edit_global_config, name="edit_global_config"), + path("hosts/new/", views.create_host_config, name="create_host_config"), 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"),