(feature) Add read-only access level to control panel

Introduce a central access policy that lets authenticated non-staff users view
backup status pages while keeping credentials, logs, configs, and mutating
actions staff-only.

Hide sensitive navigation and host controls for read-only users, expose only
the status API to authenticated viewers, and document the two access levels.
This commit is contained in:
2026-05-28 22:00:16 +02:00
parent 7f2bbe4d20
commit 81ee848f5f
13 changed files with 329 additions and 126 deletions

View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from collections.abc import Callable
from functools import wraps
from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.http import HttpRequest, HttpResponse
def can_view_status(user) -> bool:
return bool(user.is_authenticated)
def can_manage_control_panel(user) -> bool:
return bool(user.is_authenticated and user.is_staff)
def status_view_required(view_func: Callable[..., HttpResponse]) -> Callable[..., HttpResponse]:
return login_required(view_func)
def control_panel_admin_required(view_func: Callable[..., HttpResponse]) -> Callable[..., HttpResponse]:
@login_required
@wraps(view_func)
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if not can_manage_control_panel(request.user):
raise PermissionDenied
return view_func(request, *args, **kwargs)
return wrapper
def access_context(request: HttpRequest) -> dict[str, Any]:
return {
"can_view_status": can_view_status(request.user),
"can_manage_control_panel": can_manage_control_panel(request.user),
}

View File

@@ -2,16 +2,16 @@ from __future__ import annotations
from typing import Any
from django.contrib.admin.views.decorators import staff_member_required
from django.db import connection
from django.db.models import Count
from django.http import JsonResponse
from django.utils import timezone
from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
from .access import control_panel_admin_required, status_view_required
@staff_member_required
@control_panel_admin_required
def api_index(request) -> JsonResponse:
return JsonResponse(
{
@@ -26,7 +26,7 @@ def api_index(request) -> JsonResponse:
)
@staff_member_required
@status_view_required
def status(request) -> JsonResponse:
latest_run = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at").first()
latest_schedule = ScheduleConfig.objects.select_related("host").order_by("-last_started_at", "-updated_at").first()
@@ -55,7 +55,7 @@ def status(request) -> JsonResponse:
)
@staff_member_required
@control_panel_admin_required
def hosts(request) -> JsonResponse:
host_qs = (
HostConfig.objects.annotate(snapshot_count=Count("snapshots", distinct=True), run_count=Count("runs", distinct=True))
@@ -65,7 +65,7 @@ def hosts(request) -> JsonResponse:
return JsonResponse({"ok": True, "hosts": [_host_payload(host) for host in host_qs]})
@staff_member_required
@control_panel_admin_required
def snapshots(request) -> JsonResponse:
snapshot_qs = SnapshotRecord.objects.select_related("host", "base").order_by("host__host", "-started_at", "dirname")
host_filter = request.GET.get("host")
@@ -78,7 +78,7 @@ def snapshots(request) -> JsonResponse:
return JsonResponse({"ok": True, "snapshots": [_snapshot_payload(snapshot) for snapshot in snapshot_qs[:limit]]})
@staff_member_required
@control_panel_admin_required
def runs(request) -> JsonResponse:
run_qs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")
host_filter = request.GET.get("host")

View File

@@ -0,0 +1,9 @@
from __future__ import annotations
from django.http import HttpRequest
from .access import access_context
def pobsync_access(request: HttpRequest) -> dict[str, object]:
return access_context(request)

View File

@@ -919,17 +919,23 @@
<span class="nav-primary" aria-label="Primary navigation">
<a href="{% url 'dashboard' %}" {% if request.resolver_match.url_name == "dashboard" %}aria-current="page"{% endif %}>Dashboard</a>
<a href="{% url 'hosts_list' %}" {% if request.resolver_match.url_name == "hosts_list" or request.resolver_match.url_name == "host_detail" or request.resolver_match.url_name == "create_host_config" or request.resolver_match.url_name == "edit_host_config" or request.resolver_match.url_name == "edit_host_schedule" %}aria-current="page"{% endif %}>Hosts</a>
<a href="{% url 'ssh_credentials' %}" {% if request.resolver_match.url_name == "ssh_credentials" or request.resolver_match.url_name == "create_ssh_credential" or request.resolver_match.url_name == "generate_ssh_credential" or request.resolver_match.url_name == "edit_ssh_credential" %}aria-current="page"{% endif %}>SSH Keys</a>
<a href="{% url 'notification_targets' %}" {% if request.resolver_match.url_name == "notification_targets" or request.resolver_match.url_name == "create_notification_target" or request.resolver_match.url_name == "edit_notification_target" %}aria-current="page"{% endif %}>Notifications</a>
<a href="{% url 'logs' %}" {% if request.resolver_match.url_name == "logs" %}aria-current="page"{% endif %}>Logs</a>
{% if can_manage_control_panel %}
<a href="{% url 'ssh_credentials' %}" {% if request.resolver_match.url_name == "ssh_credentials" or request.resolver_match.url_name == "create_ssh_credential" or request.resolver_match.url_name == "generate_ssh_credential" or request.resolver_match.url_name == "edit_ssh_credential" %}aria-current="page"{% endif %}>SSH Keys</a>
<a href="{% url 'notification_targets' %}" {% if request.resolver_match.url_name == "notification_targets" or request.resolver_match.url_name == "create_notification_target" or request.resolver_match.url_name == "edit_notification_target" %}aria-current="page"{% endif %}>Notifications</a>
<a href="{% url 'logs' %}" {% if request.resolver_match.url_name == "logs" %}aria-current="page"{% endif %}>Logs</a>
{% endif %}
<a href="{% url 'purged_snapshots' %}" {% if request.resolver_match.url_name == "purged_snapshots" %}aria-current="page"{% endif %}>Purged</a>
</span>
<span class="spacer"></span>
<span class="nav-secondary" aria-label="System navigation">
<a href="{% url 'self_check' %}" {% if request.resolver_match.url_name == "self_check" %}aria-current="page"{% endif %}>Self Check</a>
{% if can_manage_control_panel %}
<a href="{% url 'self_check' %}" {% if request.resolver_match.url_name == "self_check" %}aria-current="page"{% endif %}>Self Check</a>
{% endif %}
<a href="{% url 'changelog' %}" {% if request.resolver_match.url_name == "changelog" %}aria-current="page"{% endif %}>Changelog</a>
<a href="/api/status/">Status API</a>
<a href="{% url 'admin:index' %}">Admin</a>
{% if can_manage_control_panel %}
<a href="{% url 'admin:index' %}">Admin</a>
{% endif %}
</span>
<span class="muted nav-user">{{ request.user.username }}</span>
</nav>

View File

@@ -9,27 +9,31 @@
<h1>Dashboard</h1>
<div class="page-subtitle">Backup health, required action, storage pressure, and recent activity in one place.</div>
</div>
<section class="actions" aria-label="Dashboard actions">
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
<a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a>
</section>
{% if can_manage_control_panel %}
<section class="actions" aria-label="Dashboard actions">
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
<a class="button-link secondary" href="{% url 'edit_global_config' %}">{% if global_config %}Edit global config{% else %}Create global config{% endif %}</a>
</section>
{% endif %}
</header>
{% if not global_config or not counts.hosts %}
<section class="panel">
<h2>Setup</h2>
{% if not global_config %}
<p class="muted">No default global config exists yet. Create it first so discovery, backups, and retention know where pobsync stores snapshots.</p>
<div class="actions inline">
<a class="button-link" href="{% url 'edit_global_config' %}">Create global config</a>
</div>
{% elif not counts.hosts %}
<p class="muted">Global config is ready. Add the first host to make this dashboard useful.</p>
<div class="actions inline">
<a class="button-link" href="{% url 'create_host_config' %}">Add first host</a>
</div>
{% endif %}
</section>
{% if can_manage_control_panel %}
{% if not global_config or not counts.hosts %}
<section class="panel">
<h2>Setup</h2>
{% if not global_config %}
<p class="muted">No default global config exists yet. Create it first so discovery, backups, and retention know where pobsync stores snapshots.</p>
<div class="actions inline">
<a class="button-link" href="{% url 'edit_global_config' %}">Create global config</a>
</div>
{% elif not counts.hosts %}
<p class="muted">Global config is ready. Add the first host to make this dashboard useful.</p>
<div class="actions inline">
<a class="button-link" href="{% url 'create_host_config' %}">Add first host</a>
</div>
{% endif %}
</section>
{% endif %}
{% endif %}
<div

View File

@@ -27,10 +27,12 @@
{{ retention_warning.incomplete_count }} incomplete snapshot(s) exist. Retention does not delete incomplete
snapshots automatically; inspect them before cleanup.
</div>
<form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary">Mark incomplete reviewed</button>
</form>
{% if can_manage_control_panel %}
<form method="post" action="{% url 'resolve_host_incomplete_reviews' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary">Mark incomplete reviewed</button>
</form>
{% endif %}
{% endif %}
{% if retention_warning.error %}
<div>{{ retention_warning.error }}</div>
@@ -80,8 +82,9 @@
</div>
</article>
<article class="panel host-control-panel">
<h2>Backup Control</h2>
{% if can_manage_control_panel %}
<article class="panel host-control-panel">
<h2>Backup Control</h2>
<div class="operator-state">
{% if active_run %}
<span class="status {{ active_run.status }}">{{ active_run.status }}</span>
@@ -121,10 +124,16 @@
<p class="muted">Real backups are blocked by failed preflight checks. Dry-runs may still be available when storage-only checks fail.</p>
{% endif %}
{% endif %}
</article>
</article>
{% endif %}
<article class="panel host-control-panel">
<h2>Schedule <a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a></h2>
<h2>
Schedule
{% if can_manage_control_panel %}
<a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Edit schedule</a>
{% endif %}
</h2>
{% if schedule %}
<div class="host-control-meta">
<div><span class="label">Schedule expression</span><strong>{{ schedule.cron_expr }}</strong></div>
@@ -136,7 +145,9 @@
<p class="muted">Evaluated by the pobsync scheduler service.</p>
{% else %}
<p class="muted">No schedule configured.</p>
<a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Add schedule</a>
{% if can_manage_control_panel %}
<a class="button-link secondary compact" href="{% url 'edit_host_schedule' host.host %}">Add schedule</a>
{% endif %}
{% endif %}
</article>
@@ -252,8 +263,9 @@
</section>
{% endif %}
<section class="panel">
<h2>Host Check</h2>
{% if can_manage_control_panel %}
<section class="panel">
<h2>Host Check</h2>
<section class="grid" aria-label="Host check summary">
<div class="metric"><div class="label">OK</div><div class="value">{{ host_check_summary.ok }}</div></div>
<div class="metric"><div class="label">Warnings</div><div class="value">{{ host_check_summary.warning }}</div></div>
@@ -279,26 +291,32 @@
</article>
{% endfor %}
</div>
</section>
</section>
{% endif %}
<div class="panel-grid">
<section class="panel">
<h2>Configuration</h2>
<div class="host-control-meta">
<div><span class="label">Address</span><strong>{{ host.address }}</strong></div>
<div><span class="label">SSH key</span><strong>{{ host.ssh_credential|default:"global default" }}</strong></div>
<div><span class="label">SSH</span><strong>{{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</strong></div>
{% if can_manage_control_panel %}
<div><span class="label">SSH key</span><strong>{{ host.ssh_credential|default:"global default" }}</strong></div>
<div><span class="label">SSH</span><strong>{{ host.ssh_user|default:"global" }}{% if host.ssh_port %}:{{ host.ssh_port }}{% endif %}</strong></div>
{% endif %}
<div><span class="label">Backup source</span><strong>{{ host.source_root|default:"global default" }}</strong></div>
<div><span class="label">Retention</span><strong>d{{ host.retention_daily }} w{{ host.retention_weekly }} m{{ host.retention_monthly }} y{{ host.retention_yearly }}</strong></div>
</div>
<div class="actions inline">
<a class="button-link secondary compact" href="{% url 'edit_host_config' host.host %}">Edit config</a>
<a class="button-link secondary compact" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
</div>
{% if can_manage_control_panel %}
<div class="actions inline">
<a class="button-link secondary compact" href="{% url 'edit_host_config' host.host %}">Edit config</a>
<a class="button-link secondary compact" href="{% url 'host_retention_plan' host.host %}">Plan retention</a>
</div>
{% endif %}
</section>
<section class="panel">
<h2>Connection Preflight &amp; SSH</h2>
{% if can_manage_control_panel %}
<section class="panel">
<h2>Connection Preflight &amp; SSH</h2>
{% if last_preflight %}
<div class="host-control-meta">
<div>
@@ -341,7 +359,8 @@
{% endfor %}
</div>
{% endif %}
</section>
</section>
{% endif %}
<section class="panel">
<h2>Snapshot Storage</h2>
@@ -360,16 +379,18 @@
</div>
{% endif %}
</div>
<div class="actions inline">
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary compact">Discover snapshots</button>
</form>
<form method="post" action="{% url 'prepare_host_directories' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary compact">Prepare directories</button>
</form>
</div>
{% if can_manage_control_panel %}
<div class="actions inline">
<form method="post" action="{% url 'discover_host_snapshots' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary compact">Discover snapshots</button>
</form>
<form method="post" action="{% url 'prepare_host_directories' host.host %}">
{% csrf_token %}
<button type="submit" class="secondary compact">Prepare directories</button>
</form>
</div>
{% endif %}
</section>
</div>
@@ -451,8 +472,9 @@
</section>
{% endif %}
<section class="panel">
<h2>Backup Options</h2>
{% if can_manage_control_panel %}
<section class="panel">
<h2>Backup Options</h2>
<p class="muted">Use this when the quick actions above need a custom label, include/exclude override, or prune limit.</p>
<form method="post" action="{% url 'queue_manual_backup' host.host %}" class="form-grid">
{% csrf_token %}
@@ -471,7 +493,8 @@
<button type="submit" {% if not can_queue_dry_run and not can_queue_real_backup %}disabled{% endif %}>Queue with options</button>
</div>
</form>
</section>
</section>
{% endif %}
<section class="panel">
<h2>Latest Runs <a class="button-link secondary compact" href="{% url 'runs_list' %}?host={{ host.host }}">View all</a></h2>

View File

@@ -9,9 +9,11 @@
<h1>Hosts</h1>
<div class="page-subtitle">Configured backup targets, schedules, retention state, and host-level controls.</div>
</div>
<section class="actions" aria-label="Host actions">
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
</section>
{% if can_manage_control_panel %}
<section class="actions" aria-label="Host actions">
<a class="button-link" href="{% url 'create_host_config' %}">New host</a>
</section>
{% endif %}
</header>
<section class="grid dashboard-summary-grid" aria-label="Host summary">

View File

@@ -32,7 +32,7 @@
</section>
{% endif %}
{% if run.status == "failed" or run.status == "warning" %}
{% if can_manage_control_panel and run.status == "failed" or can_manage_control_panel and run.status == "warning" %}
{% if not run.reviewed_at %}
<section class="panel highlight warning">
<h2>Review Required</h2>
@@ -78,7 +78,7 @@
{% endif %}
<div>
<strong>Log:</strong>
{% if dry_run_summary.log_available %}
{% if dry_run_summary.log_available and can_manage_control_panel %}
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
{% elif rsync_log_path %}
<span class="muted">{{ rsync_log_path }} (missing)</span>
@@ -155,7 +155,7 @@
{% if live_progress.log.path %}
<div>
<strong>Log:</strong>
{% if live_progress.log.exists %}
{% if live_progress.log.exists and can_manage_control_panel %}
<a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a>
{% else %}
<span class="muted">{{ live_progress.log.path }} (missing)</span>
@@ -189,7 +189,7 @@
<div><strong>Base:</strong> {{ run.base_path|default:"" }}</div>
<div>
<strong>Rsync log:</strong>
{% if rsync_log_exists %}
{% if rsync_log_exists and can_manage_control_panel %}
<a href="{% url 'run_rsync_log' run.id %}">{{ rsync_log_path }}</a>
{% elif rsync_log_path %}
<span class="muted">{{ rsync_log_path }} (missing)</span>
@@ -204,7 +204,7 @@
<section class="panel">
<h2>Rsync Log</h2>
<div class="stack spaced">
{% if rsync_log_exists %}
{% if rsync_log_exists and can_manage_control_panel %}
<div><a href="{% url 'run_rsync_log' run.id %}">Open full rsync log</a></div>
<div class="muted">{{ rsync_log_path }}</div>
{% elif rsync_log_path %}

View File

@@ -18,6 +18,12 @@ class ApiTests(TestCase):
is_staff=True,
is_superuser=True,
)
self.readonly_user = user_model.objects.create_user(
username="viewer",
password="secret",
is_staff=False,
is_superuser=False,
)
def test_api_requires_staff_login(self) -> None:
response = self.client.get("/api/hosts/")
@@ -25,6 +31,15 @@ class ApiTests(TestCase):
self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login/", response["Location"])
def test_readonly_user_can_access_status_endpoint_only(self) -> None:
self.client.force_login(self.readonly_user)
status_response = self.client.get("/api/status/")
hosts_response = self.client.get("/api/hosts/")
self.assertEqual(status_response.status_code, 200)
self.assertEqual(hosts_response.status_code, 403)
def test_hosts_endpoint_returns_counts_and_schedule(self) -> None:
self.client.force_login(self.staff_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")

View File

@@ -36,6 +36,12 @@ class ViewTests(TestCase):
is_staff=True,
is_superuser=True,
)
self.readonly_user = user_model.objects.create_user(
username="viewer",
password="secret",
is_staff=False,
is_superuser=False,
)
def test_dashboard_requires_staff_login(self) -> None:
response = self.client.get(reverse("dashboard"))
@@ -63,6 +69,22 @@ class ViewTests(TestCase):
self.assertContains(response, reverse("admin:index"))
self.assertContains(response, '<a href="/" aria-current="page">Dashboard</a>', html=False)
def test_readonly_navigation_hides_admin_and_sensitive_links(self) -> None:
self.client.force_login(self.readonly_user)
response = self.client.get(reverse("dashboard"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, reverse("dashboard"))
self.assertContains(response, reverse("hosts_list"))
self.assertContains(response, reverse("changelog"))
self.assertContains(response, "/api/status/")
self.assertNotContains(response, reverse("ssh_credentials"))
self.assertNotContains(response, reverse("notification_targets"))
self.assertNotContains(response, reverse("logs"))
self.assertNotContains(response, reverse("self_check"))
self.assertNotContains(response, reverse("admin:index"))
def test_base_navigation_marks_current_secondary_page(self) -> None:
self.client.force_login(self.staff_user)
@@ -71,12 +93,81 @@ class ViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, f'<a href="{reverse("self_check")}" aria-current="page">Self Check</a>', html=False)
def test_changelog_requires_staff_login(self) -> None:
def test_changelog_requires_login(self) -> None:
response = self.client.get(reverse("changelog"))
self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login/", response["Location"])
def test_readonly_user_can_view_status_pages(self) -> None:
self.client.force_login(self.readonly_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
snapshot = SnapshotRecord.objects.create(
host=host,
kind=SnapshotRecord.Kind.SCHEDULED,
dirname="20260519-021500Z__ABCDEFGH",
path="/backups/web-01/scheduled/20260519-021500Z__ABCDEFGH",
status="success",
)
run = BackupRun.objects.create(host=host, status=BackupRun.Status.SUCCESS, snapshot=snapshot)
urls = [
reverse("dashboard"),
reverse("hosts_list"),
reverse("host_detail", args=[host.host]),
reverse("runs_list"),
reverse("run_detail", args=[run.id]),
reverse("snapshots_list"),
reverse("snapshot_detail", args=[snapshot.id]),
reverse("schedules_list"),
reverse("purged_snapshots"),
]
for url in urls:
with self.subTest(url=url):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_readonly_user_cannot_access_sensitive_or_mutating_views(self) -> None:
self.client.force_login(self.readonly_user)
host = HostConfig.objects.create(host="web-01", address="web-01.example.test")
blocked_urls = [
reverse("ssh_credentials"),
reverse("logs"),
reverse("self_check"),
reverse("edit_global_config"),
reverse("create_host_config"),
reverse("edit_host_config", args=[host.host]),
reverse("edit_host_schedule", args=[host.host]),
]
for url in blocked_urls:
with self.subTest(url=url):
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_readonly_host_detail_hides_backup_controls_and_sensitive_config(self) -> None:
self.client.force_login(self.readonly_user)
GlobalConfig.objects.create(name="default", backup_root="/backups")
credential = SshCredential.objects.create(name="root-key", key_path="/var/lib/pobsync/state/root-key")
host = HostConfig.objects.create(
host="web-01",
address="web-01.example.test",
ssh_credential=credential,
ssh_user="root",
)
response = self.client.get(reverse("host_detail", args=[host.host]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Host Status")
self.assertNotContains(response, "Backup Control")
self.assertNotContains(response, "Backup Options")
self.assertNotContains(response, "Connection Preflight")
self.assertNotContains(response, "root-key")
self.assertNotContains(response, reverse("queue_manual_backup", args=[host.host]))
def test_changelog_renders_repository_changelog(self) -> None:
self.client.force_login(self.staff_user)
with TemporaryDirectory() as tmp:

View File

@@ -10,7 +10,6 @@ from pathlib import Path
from urllib.parse import urlencode
from django.contrib import messages
from django.contrib.admin.views.decorators import staff_member_required
from django.conf import settings
from django.http import FileResponse, Http404
from django.db.models import Count, Q
@@ -24,6 +23,7 @@ from pobsync import __version__
from pobsync.errors import PobsyncError
from pobsync.config.defaults import DEFAULT_EXCLUDES, DEFAULT_RSYNC_ARGS
from .access import access_context, control_panel_admin_required, status_view_required
from .backup_runner import queue_backup_run
from .config_checks import collect_effective_host_config_checks, collect_global_config_checks
from .forms import (
@@ -59,19 +59,19 @@ from .ssh_keys import SshKeyError, delete_generated_key_files, generate_ssh_key,
from .stats_summary import collect_dashboard_stats, collect_host_stats
@staff_member_required
@status_view_required
def dashboard(request):
return render(request, "pobsync_backend/dashboard.html", _dashboard_context())
return render(request, "pobsync_backend/dashboard.html", _dashboard_context(request))
@staff_member_required
@status_view_required
def dashboard_priority_live(request):
return render(request, "pobsync_backend/partials/dashboard_priority.html", _dashboard_context())
return render(request, "pobsync_backend/partials/dashboard_priority.html", _dashboard_context(request))
@staff_member_required
@status_view_required
def dashboard_hosts_live(request):
return render(request, "pobsync_backend/partials/dashboard_hosts.html", _dashboard_context())
return render(request, "pobsync_backend/partials/dashboard_hosts.html", _dashboard_context(request))
def _host_cards_context(*, enabled: str = "") -> dict[str, object]:
@@ -120,7 +120,7 @@ def _host_cards_context(*, enabled: str = "") -> dict[str, object]:
}
def _dashboard_context() -> dict[str, object]:
def _dashboard_context(request) -> dict[str, object]:
global_config = GlobalConfig.objects.filter(name="default").first()
host_context = _host_cards_context()
hosts = host_context["hosts"]
@@ -129,6 +129,7 @@ def _dashboard_context() -> dict[str, object]:
recent_runs = BackupRun.objects.select_related("host", "snapshot").order_by("-created_at", "-id")[:6]
stats_summary = collect_dashboard_stats(hosts=hosts, global_config=global_config)
context = {
**access_context(request),
"hosts": hosts,
"global_config": global_config,
"stats_summary": stats_summary,
@@ -159,7 +160,7 @@ def _dashboard_context() -> dict[str, object]:
return context
@staff_member_required
@status_view_required
def hosts_list(request):
enabled = request.GET.get("enabled", "").strip()
if enabled not in {"", "yes", "no"}:
@@ -171,15 +172,16 @@ def hosts_list(request):
request,
"pobsync_backend/hosts_list.html",
{
**access_context(request),
**context,
"global_config": global_config,
"show_host_controls": True,
"show_host_controls": request.user.is_staff,
"total_count": HostConfig.objects.count(),
},
)
@staff_member_required
@control_panel_admin_required
@require_POST
def update_host_state(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
@@ -290,7 +292,7 @@ def _retention_warning_summary(retention_warning) -> str:
return " ".join(parts)
@staff_member_required
@status_view_required
def changelog(request):
changelog_path = Path(settings.BASE_DIR) / "CHANGELOG.md"
try:
@@ -312,7 +314,7 @@ def changelog(request):
)
@staff_member_required
@control_panel_admin_required
def self_check(request):
checks = collect_self_checks()
return render(
@@ -325,13 +327,13 @@ def self_check(request):
)
@staff_member_required
@control_panel_admin_required
def logs(request):
context = _log_context(request)
return render(request, "pobsync_backend/logs.html", context)
@staff_member_required
@control_panel_admin_required
def notification_targets(request):
targets = NotificationTarget.objects.order_by("name")
deliveries = NotificationDelivery.objects.select_related("target", "run", "run__host").order_by("-created_at")[:12]
@@ -345,7 +347,7 @@ def notification_targets(request):
)
@staff_member_required
@control_panel_admin_required
def create_notification_target(request):
if request.method == "POST":
form = NotificationTargetForm(request.POST)
@@ -367,7 +369,7 @@ def create_notification_target(request):
)
@staff_member_required
@control_panel_admin_required
def edit_notification_target(request, target_id: int):
target = get_object_or_404(NotificationTarget, id=target_id)
if request.method == "POST":
@@ -390,7 +392,7 @@ def edit_notification_target(request, target_id: int):
)
@staff_member_required
@status_view_required
def runs_list(request):
status = request.GET.get("status", "").strip()
run_type = request.GET.get("type", "").strip()
@@ -422,7 +424,7 @@ def runs_list(request):
return render(request, "pobsync_backend/runs_list.html", context)
@staff_member_required
@status_view_required
def snapshots_list(request):
kind = request.GET.get("kind", "").strip()
status = request.GET.get("status", "").strip()
@@ -448,7 +450,7 @@ def snapshots_list(request):
return render(request, "pobsync_backend/snapshots_list.html", context)
@staff_member_required
@status_view_required
def schedules_list(request):
enabled = request.GET.get("enabled", "").strip()
prune = request.GET.get("prune", "").strip()
@@ -486,7 +488,7 @@ def schedules_list(request):
return render(request, "pobsync_backend/schedules_list.html", context)
@staff_member_required
@status_view_required
def purged_snapshots(request):
host = request.GET.get("host", "").strip()
action = request.GET.get("action", "").strip()
@@ -507,7 +509,7 @@ def purged_snapshots(request):
return render(request, "pobsync_backend/purged_snapshots.html", context)
@staff_member_required
@control_panel_admin_required
def ssh_credentials(request):
context = {
"credentials": SshCredential.objects.order_by("name"),
@@ -515,7 +517,7 @@ def ssh_credentials(request):
return render(request, "pobsync_backend/ssh_credentials.html", context)
@staff_member_required
@control_panel_admin_required
def create_ssh_credential(request):
if request.method == "POST":
form = SshCredentialForm(request.POST, request.FILES)
@@ -536,7 +538,7 @@ def create_ssh_credential(request):
)
@staff_member_required
@control_panel_admin_required
def generate_ssh_credential(request):
if request.method == "POST":
form = SshCredentialGenerateForm(request.POST)
@@ -572,7 +574,7 @@ def generate_ssh_credential(request):
)
@staff_member_required
@control_panel_admin_required
def edit_ssh_credential(request, credential_id: int):
credential = get_object_or_404(SshCredential, id=credential_id)
if request.method == "POST":
@@ -594,7 +596,7 @@ def edit_ssh_credential(request, credential_id: int):
)
@staff_member_required
@control_panel_admin_required
@require_POST
def delete_ssh_credential(request, credential_id: int):
credential = get_object_or_404(SshCredential, id=credential_id)
@@ -618,7 +620,7 @@ def delete_ssh_credential(request, credential_id: int):
return redirect("ssh_credentials")
@staff_member_required
@control_panel_admin_required
def edit_global_config(request):
global_config = GlobalConfig.objects.filter(name="default").first()
if request.method == "POST":
@@ -644,7 +646,7 @@ def edit_global_config(request):
)
@staff_member_required
@control_panel_admin_required
def create_host_config(request):
if request.method == "POST":
form = CreateHostConfigForm(request.POST)
@@ -672,7 +674,7 @@ def create_host_config(request):
)
@staff_member_required
@status_view_required
def host_detail(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
global_config = GlobalConfig.objects.filter(name="default").first()
@@ -685,7 +687,9 @@ def host_detail(request, host: str):
has_global_config = global_config is not None
backup_gate = collect_backup_gate(host_config, global_config)
stats_summary = collect_host_stats(host=host_config, limit=10)
can_manage = request.user.is_staff
context = {
**access_context(request),
"host": host_config,
"schedule": schedule,
"retention_warning": _retention_warning_for_host(host_config, schedule),
@@ -696,11 +700,11 @@ def host_detail(request, host: str):
"host_check_summary": summarize_self_checks(backup_gate.checks),
"backup_gate": backup_gate,
"last_preflight": (host_config.config or {}).get("last_preflight") if isinstance(host_config.config, dict) else {},
"effective_config": effective_host_config_preview(host_config, global_config) if global_config else {},
"effective_config": effective_host_config_preview(host_config, global_config) if global_config and can_manage else {},
"stats_summary": stats_summary,
"manual_backup_form": ManualBackupForm(initial=_default_manual_backup_initial(host_config)),
"can_queue_dry_run": host_config.enabled and has_global_config and backup_gate.can_queue_dry_run and active_run is None,
"can_queue_real_backup": host_config.enabled and has_global_config and backup_gate.can_queue_real and active_run is None,
"can_queue_dry_run": can_manage and host_config.enabled and has_global_config and backup_gate.can_queue_dry_run and active_run is None,
"can_queue_real_backup": can_manage and host_config.enabled and has_global_config and backup_gate.can_queue_real and active_run is None,
"has_global_config": has_global_config,
"active_run": active_run,
"latest_runs": host_config.runs.select_related("snapshot").order_by("-created_at")[:10],
@@ -720,7 +724,7 @@ def host_detail(request, host: str):
return render(request, "pobsync_backend/host_detail.html", context)
@staff_member_required
@control_panel_admin_required
@require_POST
def prepare_host_directories(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
@@ -733,7 +737,7 @@ def prepare_host_directories(request, host: str):
return redirect("host_detail", host=host_config.host)
@staff_member_required
@control_panel_admin_required
@require_POST
def scan_host_known_key(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
@@ -755,7 +759,7 @@ def scan_host_known_key(request, host: str):
return redirect("host_detail", host=host_config.host)
@staff_member_required
@control_panel_admin_required
@require_POST
def run_host_preflight(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
@@ -783,7 +787,7 @@ def run_host_preflight(request, host: str):
return redirect("host_detail", host=host_config.host)
@staff_member_required
@control_panel_admin_required
@require_POST
def queue_manual_backup(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
@@ -823,22 +827,22 @@ def queue_manual_backup(request, host: str):
return redirect("run_detail", run_id=run.id)
@staff_member_required
@status_view_required
def run_detail(request, run_id: int):
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
return render(request, "pobsync_backend/run_detail.html", _run_detail_context(run))
return render(request, "pobsync_backend/run_detail.html", _run_detail_context(run, request=request))
@staff_member_required
@status_view_required
def run_detail_live(request, run_id: int):
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
context = _run_detail_context(run)
context = _run_detail_context(run, request=request)
response = render(request, "pobsync_backend/partials/run_detail_live.html", context)
response["X-Pobsync-Refresh-Active"] = "true" if context["can_auto_refresh"] else "false"
return response
def _run_detail_context(run: BackupRun) -> dict[str, object]:
def _run_detail_context(run: BackupRun, *, request=None) -> dict[str, object]:
result = run.result if isinstance(run.result, dict) else {}
run_stats = result.get("stats") if isinstance(result.get("stats"), dict) else {}
rsync_result = result.get("rsync") if isinstance(result.get("rsync"), dict) else {}
@@ -848,8 +852,10 @@ def _run_detail_context(run: BackupRun) -> dict[str, object]:
rsync_log_path = _run_rsync_log_path(run)
rsync_log_tail = _run_rsync_log_tail(rsync_result, rsync_log_path)
requested = result.get("requested") if isinstance(result.get("requested"), dict) else {}
can_cancel = run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING}
can_manage = bool(request and request.user.is_staff)
can_cancel = can_manage and run.status in {BackupRun.Status.QUEUED, BackupRun.Status.RUNNING}
return {
**(access_context(request) if request is not None else {}),
"run": run,
"can_cancel": can_cancel,
"can_auto_refresh": can_cancel,
@@ -880,7 +886,7 @@ def _run_detail_context(run: BackupRun) -> dict[str, object]:
}
@staff_member_required
@control_panel_admin_required
def run_rsync_log(request, run_id: int):
run = get_object_or_404(BackupRun.objects.select_related("host", "snapshot"), id=run_id)
log_path = _run_rsync_log_path(run)
@@ -889,7 +895,7 @@ def run_rsync_log(request, run_id: int):
return FileResponse(log_path.open("rb"), content_type="text/plain; charset=utf-8")
@staff_member_required
@control_panel_admin_required
@require_POST
def cancel_run(request, run_id: int):
run = get_object_or_404(BackupRun.objects.select_related("host"), id=run_id)
@@ -913,7 +919,7 @@ def cancel_run(request, run_id: int):
return redirect("run_detail", run_id=run.id)
@staff_member_required
@control_panel_admin_required
@require_POST
def resolve_run_review(request, run_id: int):
run = get_object_or_404(BackupRun.objects.select_related("host"), id=run_id)
@@ -931,7 +937,7 @@ def resolve_run_review(request, run_id: int):
return _redirect_after_run_review(request, run)
@staff_member_required
@control_panel_admin_required
@require_POST
def resolve_host_incomplete_reviews(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
@@ -947,7 +953,7 @@ def resolve_host_incomplete_reviews(request, host: str):
return redirect("host_detail", host=host_config.host)
@staff_member_required
@status_view_required
def snapshot_detail(request, snapshot_id: int):
snapshot = get_object_or_404(
SnapshotRecord.objects.select_related("host", "base").prefetch_related("derived_snapshots", "backup_runs"),
@@ -965,7 +971,7 @@ def snapshot_detail(request, snapshot_id: int):
return render(request, "pobsync_backend/snapshot_detail.html", context)
@staff_member_required
@control_panel_admin_required
@require_POST
def discover_host_snapshots(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
@@ -986,7 +992,7 @@ def discover_host_snapshots(request, host: str):
return redirect("host_detail", host=host_config.host)
@staff_member_required
@control_panel_admin_required
def host_retention_plan(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
kind = request.GET.get("kind", "scheduled")
@@ -1037,7 +1043,7 @@ def host_retention_plan(request, host: str):
return render(request, "pobsync_backend/retention_plan.html", context)
@staff_member_required
@control_panel_admin_required
@require_POST
def apply_host_retention(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
@@ -1091,7 +1097,7 @@ def apply_host_retention(request, host: str):
return target
@staff_member_required
@control_panel_admin_required
@require_POST
def cleanup_host_incomplete_snapshots(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
@@ -1126,7 +1132,7 @@ def cleanup_host_incomplete_snapshots(request, host: str):
return redirect("host_retention_plan", host=host_config.host)
@staff_member_required
@control_panel_admin_required
def edit_host_config(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
global_config = GlobalConfig.objects.filter(name="default").first()
@@ -1152,7 +1158,7 @@ def edit_host_config(request, host: str):
)
@staff_member_required
@control_panel_admin_required
def edit_host_schedule(request, host: str):
host_config = get_object_or_404(HostConfig, host=host)
schedule = _schedule_for_host(host_config)

View File

@@ -48,6 +48,7 @@ TEMPLATES = [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"pobsync_backend.context_processors.pobsync_access",
],
},
},
@@ -55,6 +56,8 @@ TEMPLATES = [
WSGI_APPLICATION = "pobsync_server.wsgi.application"
LOGIN_URL = "/admin/login/"
def _database_config() -> dict[str, object]:
engine = os.getenv("POBSYNC_DB_ENGINE", "sqlite").strip().lower()