(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.
This commit is contained in:
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from .models import HostConfig, ScheduleConfig
|
from .models import GlobalConfig, HostConfig, ScheduleConfig
|
||||||
from .scheduler import parse_cron_expr
|
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):
|
class ScheduleConfigForm(forms.ModelForm):
|
||||||
cron_expr = forms.CharField(
|
cron_expr = forms.CharField(
|
||||||
label="Cron expression",
|
label="Cron expression",
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
.stack { display: grid; gap: 4px; }
|
.stack { display: grid; gap: 4px; }
|
||||||
.two-col { display: grid; gap: 18px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
.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 { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 18px; }
|
||||||
|
.actions.inline { margin: 12px 0 0; }
|
||||||
button, .button-link {
|
button, .button-link {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: #17202a;
|
background: #17202a;
|
||||||
@@ -92,6 +93,12 @@
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
button:hover, .button-link:hover { background: #2a394a; text-decoration: none; }
|
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; }
|
.messages { display: grid; gap: 8px; margin-bottom: 18px; }
|
||||||
.message {
|
.message {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
@@ -113,6 +120,7 @@
|
|||||||
}
|
}
|
||||||
.field textarea { min-height: 92px; resize: vertical; }
|
.field textarea { min-height: 92px; resize: vertical; }
|
||||||
.field .helptext { color: var(--muted); font-size: 12px; }
|
.field .helptext { color: var(--muted); font-size: 12px; }
|
||||||
|
.field input[type="checkbox"] { justify-self: start; }
|
||||||
.errorlist {
|
.errorlist {
|
||||||
color: var(--failed);
|
color: var(--failed);
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|||||||
@@ -5,7 +5,30 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Dashboard</h1>
|
<h1>Dashboard</h1>
|
||||||
|
|
||||||
|
<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 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 %}
|
||||||
|
|
||||||
<section class="grid" aria-label="Summary">
|
<section class="grid" aria-label="Summary">
|
||||||
|
<div class="metric"><div class="label">Global Configs</div><div class="value">{{ counts.global_configs }}</div></div>
|
||||||
<div class="metric"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></div>
|
<div class="metric"><div class="label">Hosts</div><div class="value">{{ counts.enabled_hosts }}/{{ counts.hosts }}</div></div>
|
||||||
<div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div>
|
<div class="metric"><div class="label">Schedules</div><div class="value">{{ counts.enabled_schedules }}/{{ counts.schedules }}</div></div>
|
||||||
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
|
<div class="metric"><div class="label">Snapshots</div><div class="value">{{ counts.snapshots }}</div></div>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "pobsync_backend/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Global Config{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% if global_config %}Global Config{% else %}Create Global Config{% endif %}</h1>
|
||||||
|
|
||||||
|
<section class="actions" aria-label="Global config actions">
|
||||||
|
<a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>{% if global_config %}Edit Global Config{% else %}Create Global Config{% 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 global config</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
{% extends "pobsync_backend/base.html" %}
|
{% 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 %}
|
{% block content %}
|
||||||
<h1>Config: {{ host.host }}</h1>
|
<h1>{% if host %}Config: {{ host.host }}{% else %}New Host{% endif %}</h1>
|
||||||
|
|
||||||
<section class="actions" aria-label="Config actions">
|
<section class="actions" aria-label="Config actions">
|
||||||
|
{% if host %}
|
||||||
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
|
<a class="button-link" href="{% url 'host_detail' host.host %}">Back to host</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="button-link" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2>Edit Host Config</h2>
|
<h2>{% if host %}Edit Host Config{% else %}Create Host Config{% endif %}</h2>
|
||||||
<form method="post" class="form-grid">
|
<form method="post" class="form-grid">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.non_field_errors }}
|
{{ form.non_field_errors }}
|
||||||
@@ -25,7 +29,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit">Save config</button>
|
<button type="submit">{% if host %}Save config{% else %}Create host{% endif %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -47,6 +47,102 @@ class ViewTests(TestCase):
|
|||||||
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
self.assertContains(response, "20260519-021500Z__ABCDEFGH")
|
||||||
self.assertContains(response, "success")
|
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:
|
def test_host_detail_renders_config_schedule_runs_and_snapshots(self) -> None:
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
host = HostConfig.objects.create(
|
host = HostConfig.objects.create(
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ from django.views.decorators.http import require_POST
|
|||||||
|
|
||||||
from pobsync.errors import ConfigError
|
from pobsync.errors import ConfigError
|
||||||
|
|
||||||
from .forms import HostConfigForm, ScheduleConfigForm
|
from .forms import CreateHostConfigForm, GlobalConfigForm, HostConfigForm, ScheduleConfigForm
|
||||||
from .models import BackupRun, HostConfig, ScheduleConfig, SnapshotRecord
|
from .models import BackupRun, GlobalConfig, 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
|
||||||
|
|
||||||
@@ -22,8 +22,10 @@ def dashboard(request):
|
|||||||
)
|
)
|
||||||
context = {
|
context = {
|
||||||
"hosts": host_qs,
|
"hosts": host_qs,
|
||||||
|
"global_config": GlobalConfig.objects.filter(name="default").first(),
|
||||||
"latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10],
|
"latest_runs": BackupRun.objects.select_related("host", "snapshot").order_by("-created_at")[:10],
|
||||||
"counts": {
|
"counts": {
|
||||||
|
"global_configs": GlobalConfig.objects.count(),
|
||||||
"hosts": HostConfig.objects.count(),
|
"hosts": HostConfig.objects.count(),
|
||||||
"enabled_hosts": HostConfig.objects.filter(enabled=True).count(),
|
"enabled_hosts": HostConfig.objects.filter(enabled=True).count(),
|
||||||
"schedules": ScheduleConfig.objects.count(),
|
"schedules": ScheduleConfig.objects.count(),
|
||||||
@@ -37,6 +39,49 @@ def dashboard(request):
|
|||||||
return render(request, "pobsync_backend/dashboard.html", context)
|
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
|
@staff_member_required
|
||||||
def host_detail(request, host: str):
|
def host_detail(request, host: str):
|
||||||
host_config = get_object_or_404(HostConfig, host=host)
|
host_config = get_object_or_404(HostConfig, host=host)
|
||||||
@@ -158,3 +203,29 @@ def _default_schedule_initial() -> dict[str, object]:
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
"prune_max_delete": 10,
|
"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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from pobsync_backend import api, views
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.dashboard, name="dashboard"),
|
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/<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>/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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user