mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
feat(appsettings): the Settings page groups its settings and saves a group at once
The page listed 37 settings by name, each its own form saved as soon as its list or field changed, with the description only in a tooltip. It now shows them in groups (New VM defaults, Disks, Clones, Console, VM access and owners, Snapshots and jobs, Login, Lists and pages) with each description under its field and the label tied to it. A group's Save button saves its changed settings, or none when one is refused, and says which were saved; number settings are number fields with their range. Language and theme are under Appearance with their own buttons. A setting in no group shows under Other.
This commit is contained in:
parent
7392a5b4d2
commit
632f5e15bd
3 changed files with 157 additions and 76 deletions
|
|
@ -6,60 +6,70 @@
|
|||
{% block page_heading %}{% trans "Edit Settings" %}{% endblock page_heading %}
|
||||
|
||||
{% block content %}
|
||||
<div class="">
|
||||
<div class="col-lg-12">
|
||||
<h3 class="page-header">{% trans "App Settings" %}</h3>
|
||||
<form action="{% url 'set_language' %}" method="post" style="display:inline" aria-label="Edit language.name_local settings form">{% csrf_token %}
|
||||
<div class="row mb-1">
|
||||
<input name="next" type="hidden" value="{{ redirect_to }}">
|
||||
<label class="col-sm-3 col-form-label" for="language">{% trans "Language" %}</label>
|
||||
<section class="card mb-3" aria-labelledby="appearance-heading">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 card-title" id="appearance-heading">{% trans "Appearance" %}</h2>
|
||||
<form action="{% url 'set_language' %}" method="post" class="row g-2 mb-3 align-items-start">{% csrf_token %}
|
||||
<label class="col-sm-4 col-form-label" for="language">{% trans "Language" %}</label>
|
||||
<div class="col-sm-6">
|
||||
<select id="language" name="language" class="form-select" aria-describedby="language-help">
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
{% get_available_languages as LANGUAGES %}
|
||||
{% get_language_info_list for LANGUAGES as languages %}
|
||||
{% for language in languages %}
|
||||
<option value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %} selected{% endif %}>{{ language.name_local }} ({{ language.code }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div id="language-help" class="form-text">{% trans "Yours only, in this browser" %}</div>
|
||||
</div>
|
||||
<div class="col-sm-2"><button type="submit" class="btn btn-outline-primary w-100">{% trans "Change" %}</button></div>
|
||||
</form>
|
||||
<form method="post" class="row g-2 align-items-start">{% csrf_token %}
|
||||
<label class="col-sm-4 col-form-label" for="theme">{% trans bootstrap_theme.name %}</label>
|
||||
<div class="col-sm-6">
|
||||
<select id="theme" class="form-select" name="{{ bootstrap_theme.key }}" aria-describedby="theme-help">
|
||||
{% for theme in themes_list %}
|
||||
<option value="{{ theme }}"{% if bootstrap_theme.value == theme %} selected{% endif %}>{{ theme }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div id="theme-help" class="form-text">{% trans "For every user" %}</div>
|
||||
</div>
|
||||
<div class="col-sm-2"><button type="submit" class="btn btn-outline-primary w-100">{% trans "Change" %}</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% for title, settings in groups %}
|
||||
<section class="card mb-3" aria-labelledby="group-{{ forloop.counter }}">
|
||||
<div class="card-body">
|
||||
<form method="post">{% csrf_token %}
|
||||
<h2 class="h5 card-title mb-3" id="group-{{ forloop.counter }}">{% trans title %}</h2>
|
||||
{% for setting in settings %}
|
||||
<div class="row g-2 mb-3">
|
||||
<label class="col-sm-4 col-form-label" for="setting-{{ setting.key }}">{% trans setting.name %}</label>
|
||||
<div class="col-sm-6">
|
||||
<select id="language" name="language" class="form-control" onchange="this.form.submit()">
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
{% get_available_languages as LANGUAGES %}
|
||||
{% get_language_info_list for LANGUAGES as languages %}
|
||||
{% for language in languages %}
|
||||
<option value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %} selected{% endif %}>
|
||||
{{ language.name_local }} ({{ language.code }})
|
||||
</option>
|
||||
{% if setting.choices %}
|
||||
<select id="setting-{{ setting.key }}" class="form-select" name="{{ setting.key }}" aria-describedby="help-{{ setting.key }}">
|
||||
{% for choice in setting.choices_as_list %}
|
||||
<option value="{{ choice }}"{% if setting.value == choice %} selected{% endif %}>{% trans choice %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% elif setting.bounds %}
|
||||
<input id="setting-{{ setting.key }}" class="form-control" type="number" name="{{ setting.key }}" value="{{ setting.value }}"
|
||||
min="{{ setting.bounds.0 }}" max="{{ setting.bounds.1 }}" step="1" required aria-describedby="help-{{ setting.key }}">
|
||||
{% else %}
|
||||
<input id="setting-{{ setting.key }}" class="form-control" type="text" name="{{ setting.key }}" value="{{ setting.value }}"
|
||||
maxlength="25" aria-describedby="help-{{ setting.key }}">
|
||||
{% endif %}
|
||||
<div id="help-{{ setting.key }}" class="form-text">{% trans setting.description %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% if request.user.is_superuser %}
|
||||
<form method="post" action="" role="form" aria-label="Edit theme settings form">{% csrf_token %}
|
||||
<div class="row mb-1">
|
||||
<label class="col-sm-3 col-form-label">{% trans bootstrap_theme.name %}</label>
|
||||
<div class="col-sm-6">
|
||||
<select class="form-select" name="{{ bootstrap_theme.key }}" onchange="this.form.submit()" title="{% trans bootstrap_theme.description %}">
|
||||
{% for theme in themes_list %}
|
||||
<option {% if bootstrap_theme.value == theme %}selected{% endif %} value="{{ theme }}">{{ theme }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="text-end">
|
||||
<button type="submit" class="btn btn-primary">{% trans "Save" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
<h3 class="page-header">{% trans "Other Settings" %}</h3>
|
||||
{% for setting in appsettings %}
|
||||
<form method="post" action="" role="form" aria-label="{{setting.name}} form">{% csrf_token %}
|
||||
<div class="row mb-1">
|
||||
<label class="col-sm-3 col-form-label">{% trans setting.name %}</label>
|
||||
<div class="col-sm-6">
|
||||
{% if setting.choices %}
|
||||
<select class="form-select" name="{{ setting.key }}" onchange="this.form.submit()" title="{% trans setting.description %}">
|
||||
{% for choice in setting.choices_as_list %}
|
||||
<option {% if setting.value == choice %} selected {% endif %} value="{{ choice }}">{% trans choice %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input class="form-control" name="{{ setting.key }}" value="{{ setting.value }}" title="{% trans setting.description %}" onchange="this.form.submit()"/>
|
||||
{% endif%}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -83,3 +83,45 @@ class AppSettingsTestCase(TestCase):
|
|||
page = self.client.get(reverse("appsettings")).content.decode()
|
||||
self.assertIn('name="CLONE_INSTANCE_DEFAULT_PREFIX" value="web-"', page)
|
||||
|
||||
def page(self):
|
||||
from lxml import html
|
||||
|
||||
return html.fromstring(self.client.get(reverse("appsettings")).content)
|
||||
|
||||
def test_settings_are_grouped_with_their_descriptions_shown(self):
|
||||
page = self.page()
|
||||
titles = [h.text_content().strip() for h in page.xpath("//form//h2")]
|
||||
self.assertEqual(titles[:3], ["New VM defaults", "Disks", "Clones"])
|
||||
# every setting is in one group, with a label tied to its field and its description shown
|
||||
keys = page.xpath("//form//*[self::input or self::select][@name and @name!='csrfmiddlewaretoken']/@name")
|
||||
names = set(AppSettings.objects.exclude(key="BOOTSTRAP_THEME").values_list("key", flat=True))
|
||||
self.assertEqual(sorted(k for k in keys if k not in ("BOOTSTRAP_THEME", "language")), sorted(names))
|
||||
(field,) = page.xpath("//*[@name='LOGS_PER_PAGE']")
|
||||
self.assertEqual((field.get("type"), field.get("min"), field.get("max")), ("number", "1", "1000"))
|
||||
self.assertEqual(page.xpath(f"//label[@for='{field.get('id')}']/text()"), ["Logs per Page"])
|
||||
self.assertEqual(page.xpath(f"//*[@id='{field.get('aria-describedby')}']/text()"), ["Pagination for logs"])
|
||||
# a group is saved with its button, not on every change
|
||||
self.assertEqual(page.xpath("//*[@onchange]"), [])
|
||||
|
||||
def test_a_group_saves_its_changed_settings_only(self):
|
||||
from logs.models import Logs
|
||||
|
||||
Logs.objects.all().delete()
|
||||
response = self.client.post(reverse("appsettings"), {
|
||||
"CONSOLE_SCALE": "True", "CONSOLE_VIEW_ONLY": "False",
|
||||
"CONSOLE_RESIZE_SESSION": "False", "CONSOLE_CLIP_VIEWPORT": "True"})
|
||||
self.assertRedirects(response, reverse("appsettings"))
|
||||
values = dict(AppSettings.objects.filter(key__startswith="CONSOLE_").values_list("key", "value"))
|
||||
self.assertEqual(values, {"CONSOLE_SCALE": "True", "CONSOLE_VIEW_ONLY": "False",
|
||||
"CONSOLE_RESIZE_SESSION": "False", "CONSOLE_CLIP_VIEWPORT": "True"})
|
||||
self.assertEqual(sorted(Logs.objects.values_list("message", flat=True)),
|
||||
["Setting Console Clip Viewport: change to True", "Setting Console Scale: change to True"])
|
||||
self.assertEqual([str(m) for m in get_messages(response.wsgi_request)],
|
||||
["Saved: Console Clip Viewport, Console Scale"])
|
||||
|
||||
def test_one_bad_value_saves_nothing_of_its_group(self):
|
||||
response = self.client.post(reverse("appsettings"), {"LOGIN_FAILURE_LIMIT": "10", "LOGIN_LOCKOUT_MINUTES": "x"})
|
||||
self.assertEqual(AppSettings.objects.get(key="LOGIN_FAILURE_LIMIT").value, "5")
|
||||
self.assertEqual([str(m) for m in get_messages(response.wsgi_request)],
|
||||
["Login Lockout Minutes must be a whole number between 1 and 10080"])
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from pathlib import Path
|
|||
from admin.decorators import superuser_only
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.db import DatabaseError
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.utils.translation import gettext_noop as _
|
||||
|
|
@ -29,8 +30,32 @@ NUMBERS = {
|
|||
}
|
||||
|
||||
|
||||
# the Settings page's groups, in order; a setting in none shows under Other
|
||||
GROUPS = [
|
||||
(_("New VM defaults"), ["INSTANCE_ARCH_DEFAULT_TYPE", "INSTANCE_MACHINE_DEFAULT_TYPE",
|
||||
"INSTANCE_FIRMWARE_DEFAULT_TYPE", "INSTANCE_CPU_DEFAULT_MODE", "INSTANCE_NIC_DEFAULT_TYPE",
|
||||
"INSTANCE_VIDEO_DEFAULT_TYPE", "INSTANCE_INPUT_DEFAULT_DEVICE", "INSTANCE_CDROM_ADD"]),
|
||||
(_("Disks"), ["INSTANCE_VOLUME_DEFAULT_BUS", "INSTANCE_VOLUME_DEFAULT_FORMAT", "INSTANCE_VOLUME_DEFAULT_CACHE",
|
||||
"INSTANCE_VOLUME_DEFAULT_IO", "INSTANCE_VOLUME_DEFAULT_DISCARD",
|
||||
"INSTANCE_VOLUME_DEFAULT_DETECT_ZEROES", "INSTANCE_VOLUME_DEFAULT_SCSI_CONTROLLER",
|
||||
"INSTANCE_VOLUME_DEFAULT_OWNER_UID", "INSTANCE_VOLUME_DEFAULT_OWNER_GID"]),
|
||||
(_("Clones"), ["CLONE_INSTANCE_AUTO_NAME", "CLONE_INSTANCE_DEFAULT_PREFIX"]),
|
||||
(_("Console"), ["CONSOLE_SCALE", "CONSOLE_VIEW_ONLY", "CONSOLE_RESIZE_SESSION", "CONSOLE_CLIP_VIEWPORT"]),
|
||||
(_("VM access and owners"), ["SHOW_ACCESS_ROOT_PASSWORD", "SHOW_ACCESS_SSH_KEYS",
|
||||
"ALLOW_INSTANCE_MULTIPLE_OWNER", "QUOTA_DEBUG"]),
|
||||
(_("Snapshots and jobs"), ["SNAPSHOT_FREE_SPACE_PERCENT", "SNAPSHOT_FREE_SPACE_MIN_GIB",
|
||||
"WORKER_CONCURRENCY", "JOB_RETENTION_DAYS"]),
|
||||
(_("Login"), ["LOGIN_FAILURE_LIMIT", "LOGIN_LOCKOUT_MINUTES"]),
|
||||
(_("Lists and pages"), ["VIEW_INSTANCES_LIST_STYLE", "VIEW_INSTANCE_DETAIL_BOTTOM_BAR", "VM_DRBD_STATUS",
|
||||
"LOGS_PER_PAGE"]),
|
||||
]
|
||||
|
||||
|
||||
def refusal(setting, value):
|
||||
"""Why the value cannot be saved for the setting, or None."""
|
||||
if len(value) > AppSettings._meta.get_field("value").max_length:
|
||||
return _("%(setting)s takes at most %(length)s characters") % {
|
||||
"setting": setting.name, "length": AppSettings._meta.get_field("value").max_length}
|
||||
if setting.key in NUMBERS:
|
||||
low, high = NUMBERS[setting.key]
|
||||
if not (value.isascii() and value.isdigit() and len(value) <= 10 and low <= int(value) <= high):
|
||||
|
|
@ -44,17 +69,12 @@ def refusal(setting, value):
|
|||
|
||||
@superuser_only
|
||||
def appsettings(request):
|
||||
"""
|
||||
:param request:
|
||||
:return:
|
||||
"""
|
||||
"""The settings, in groups; a group's form saves its changed settings,
|
||||
or none of them when one is refused."""
|
||||
bootstrap_theme = AppSettings.objects.get(key="BOOTSTRAP_THEME")
|
||||
themes_list = sorted(p.name.removesuffix(".min.css") for p in THEMES_DIR.glob("*.min.css"))
|
||||
|
||||
# the theme has its own form; the other settings are listed one by one
|
||||
appsettings = AppSettings.objects.exclude(
|
||||
description__startswith="Bootstrap"
|
||||
).order_by("name")
|
||||
# the theme has its own form
|
||||
settings_by_key = {s.key: s for s in AppSettings.objects.exclude(key="BOOTSTRAP_THEME")}
|
||||
|
||||
if request.method == "POST":
|
||||
if "BOOTSTRAP_THEME" in request.POST:
|
||||
|
|
@ -70,27 +90,36 @@ def appsettings(request):
|
|||
messages.success(request, msg)
|
||||
return HttpResponseRedirect(request.get_full_path())
|
||||
|
||||
for setting in appsettings:
|
||||
if setting.key in request.POST:
|
||||
value = request.POST.get(setting.key, "")
|
||||
refused = refusal(setting, value)
|
||||
if refused:
|
||||
messages.error(request, refused)
|
||||
return HttpResponseRedirect(request.get_full_path())
|
||||
try:
|
||||
posted = sorted(((s, request.POST[s.key]) for s in settings_by_key.values() if s.key in request.POST),
|
||||
key=lambda item: item[0].name)
|
||||
refusals = [refused for refused in (refusal(s, value) for s, value in posted) if refused]
|
||||
for refused in refusals:
|
||||
messages.error(request, refused)
|
||||
if not refusals:
|
||||
saved = []
|
||||
try:
|
||||
for setting, value in posted:
|
||||
if value != setting.value:
|
||||
with audit(request, None, f"Setting {setting.name}", f"change to {value}"):
|
||||
setting.value = value
|
||||
setting.save()
|
||||
saved.append(setting.name)
|
||||
except DatabaseError as err: # the audit row records it as failed
|
||||
messages.error(request, err)
|
||||
else:
|
||||
if saved:
|
||||
messages.success(request, _("Saved: %(settings)s") % {"settings": ", ".join(saved)})
|
||||
else:
|
||||
messages.info(request, _("Nothing changed"))
|
||||
return HttpResponseRedirect(request.get_full_path())
|
||||
|
||||
msg = _("%(setting)s is changed. Now: %(value)s") % {
|
||||
"setting": setting.name,
|
||||
"value": setting.value,
|
||||
}
|
||||
messages.success(request, msg)
|
||||
except Exception as err:
|
||||
msg = err
|
||||
messages.error(request, msg)
|
||||
return HttpResponseRedirect(request.get_full_path())
|
||||
|
||||
return render(request, "appsettings.html", locals())
|
||||
groups = []
|
||||
for title, keys in GROUPS:
|
||||
groups.append((title, [settings_by_key.pop(key) for key in keys if key in settings_by_key]))
|
||||
if settings_by_key:
|
||||
groups.append((_("Other"), sorted(settings_by_key.values(), key=lambda s: s.name)))
|
||||
for _title, settings_list in groups:
|
||||
for setting in settings_list:
|
||||
setting.bounds = NUMBERS.get(setting.key)
|
||||
return render(request, "appsettings.html",
|
||||
{"bootstrap_theme": bootstrap_theme, "themes_list": themes_list, "groups": groups})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue