1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00

fix(locale): the VM page's and Settings page's messages show translated

The VM page's and the Settings page's views marked their messages with
gettext_noop, so every message they gave (a refused resize, a saved
setting, a QoS change, a busy VM) showed in English whatever the
language, though the catalogs translate them. They translate in the
request's language now; their module-level texts lazily. The Settings
page's group titles stay English in the view, as the page links them
by their slugs and translates them itself.

Two values went into translated messages as English codes: a job's
state ("... is running on this resource") and a QoS direction ("Remove
the inbound limit ..."). They are named as the pages name them.
This commit is contained in:
catborise 2026-11-02 23:09:05 +03:00
parent 763f1525ca
commit 9d57adc097
10 changed files with 412 additions and 380 deletions

View file

@ -30,6 +30,12 @@ class AppSettingsTestCase(TestCase):
choice = choice[:choice.index("</select>")]
self.assertEqual(re.findall(r'<option value="([^"]+)"', choice), ["en", "tr", "zh-hans"])
def test_the_page_answers_in_the_users_language(self):
response = self.client.post(reverse("appsettings"), {}, HTTP_ACCEPT_LANGUAGE="tr", follow=True)
self.assertEqual([str(m) for m in get_messages(response.wsgi_request)], ["Hiçbir şey değişmedi"])
# the groups' links stay the English slugs: the page translates only their titles
self.assertContains(response, 'href="#new-vm-defaults"')
def test_appsettings_update_setting(self):
setting = AppSettings.objects.get(key="SHOW_ACCESS_ROOT_PASSWORD")
original_value = setting.value

View file

@ -6,7 +6,8 @@ from django.contrib import messages
from django.db import DatabaseError, transaction
from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.utils.translation import gettext_noop as _
from django.utils.translation import gettext as _
from django.utils.translation import gettext_noop
from logs.audit import audit
from operations.utils import BOUNDS
@ -30,23 +31,24 @@ NUMBERS = {
}
# the Settings page's groups, in order; a setting in none shows under Other
# the Settings page's groups, in order; a setting in none shows under Other. The
# titles stay English here: the page links them by slug and translates them
GROUPS = [
(_("New VM defaults"), ["INSTANCE_ARCH_DEFAULT_TYPE", "INSTANCE_MACHINE_DEFAULT_TYPE",
(gettext_noop("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",
(gettext_noop("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",
(gettext_noop("Clones"), ["CLONE_INSTANCE_AUTO_NAME", "CLONE_INSTANCE_DEFAULT_PREFIX"]),
(gettext_noop("Console"), ["CONSOLE_SCALE", "CONSOLE_VIEW_ONLY", "CONSOLE_RESIZE_SESSION", "CONSOLE_CLIP_VIEWPORT"]),
(gettext_noop("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",
(gettext_noop("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", "VM_DRBD_STATUS", "LOGS_PER_PAGE"]),
(gettext_noop("Login"), ["LOGIN_FAILURE_LIMIT", "LOGIN_LOCKOUT_MINUTES"]),
(gettext_noop("Lists and pages"), ["VIEW_INSTANCES_LIST_STYLE", "VM_DRBD_STATUS", "LOGS_PER_PAGE"]),
]
@ -117,7 +119,7 @@ def appsettings(request):
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)))
groups.append((gettext_noop("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)

View file

@ -1,6 +1,7 @@
{% load i18n %}
{# one QoS limit of a NIC: its values to change, or to delete #}
<form class="mt-2" style="max-width: 32rem" action="{% url 'instances:set_qos' instance.id %}" method="post" aria-label="{% blocktrans %}{{ direction }} QoS form{% endblocktrans %}">{% csrf_token %}
{% if direction == "inbound" %}{% trans "inbound" as direction_name %}{% else %}{% trans "outbound" as direction_name %}{% endif %}
<form class="mt-2" style="max-width: 32rem" action="{% url 'instances:set_qos' instance.id %}" method="post" aria-label="{% blocktrans with direction=direction_name %}{{ direction }} QoS form{% endblocktrans %}">{% csrf_token %}
<input type="hidden" name="net-mac" value="{{ network.mac }}">
<input type="hidden" name="qos_direction" value="{{ direction }}">
<div class="fw-semibold mb-1">{% if direction == "inbound" %}{% trans "Inbound" %}{% else %}{% trans "Outbound" %}{% endif %}</div>
@ -21,6 +22,6 @@
<div class="d-flex flex-wrap gap-2 mt-2">
<button type="submit" class="btn btn-sm btn-primary" name="set_qos">{% trans "Save" %}</button>
<button type="submit" class="btn btn-sm btn-danger" name="unset_qos" formaction="{% url 'instances:unset_qos' instance.id %}" formnovalidate
data-confirm="{% blocktrans with mac=network.mac %}Remove the {{ direction }} limit of {{ mac }}? A running VM keeps it until it is powered off and on.{% endblocktrans %}">{% trans "Delete limit" %}</button>
data-confirm="{% blocktrans with direction=direction_name mac=network.mac %}Remove the {{ direction }} limit of {{ mac }}? A running VM keeps it until it is powered off and on.{% endblocktrans %}">{% trans "Delete limit" %}</button>
</div>
</form>

View file

@ -35,7 +35,8 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.template.defaultfilters import filesizeformat
from django.utils import timezone
from django.utils.translation import gettext_noop as _
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy
from django.views.decorators.http import require_POST
from libvirt import (VIR_DOMAIN_PAUSED,
VIR_DOMAIN_RUNNING,
@ -741,11 +742,11 @@ def invalid_disk_options(
# libvirt's words when a running q35 VM has no free root port for a PCIe device
PCI_SLOTS_FULL = "No more available PCI slots"
# a disk on the scsi bus needs no slot of its own when the VM already has a SCSI controller
DISK_NO_PCI_SLOT = _("The running VM has no free PCI slot for another disk on this bus: choose the scsi bus "
DISK_NO_PCI_SLOT = gettext_lazy("The running VM has no free PCI slot for another disk on this bus: choose the scsi bus "
"if the VM has a SCSI controller, or shut the VM down and add the disk then; Add PCIe slots "
"on the Disks tab gives it free ones from its next start. A new volume stays in its pool, "
"to attach as an existing volume.")
SCSI_NO_PCI_SLOT = _("The running VM has no free PCI slot for the SCSI controller the disk needs: shut the VM "
SCSI_NO_PCI_SLOT = gettext_lazy("The running VM has no free PCI slot for the SCSI controller the disk needs: shut the VM "
"down and add the disk then; Add PCIe slots on the Disks tab gives it free ones from its next "
"start. A new volume stays in its pool, to attach as an existing volume.")
@ -1059,7 +1060,7 @@ def gstfsd_request(compute, data):
# root password and SSH key: gstfsd changes a shut-off VM's disk, the QEMU
# guest agent a running VM
AGENT_OR_SHUT_OFF = _("Shut the VM down, or start its QEMU guest agent, and try again")
AGENT_OR_SHUT_OFF = gettext_lazy("Shut the VM down, or start its QEMU guest agent, and try again")
# libvirt's value; bindings built against older libvirt (Ubuntu 24.04's) lack
@ -2207,13 +2208,13 @@ def set_qos(request, pk):
instance.proxy.set_qos(mac, qos_dir, average, peak, burst)
if instance.proxy.get_status() == 5:
messages.success(
request, _("%(qos_dir)s QoS is set") % {"qos_dir": qos_dir.capitalize()}
request, _("%(qos_dir)s QoS is set") % {"qos_dir": _(qos_dir.capitalize())}
)
else:
messages.success(
request,
_("%(qos_dir)s QoS is set. A running VM gets it after it is powered off and on.")
% {"qos_dir": qos_dir.capitalize()},
% {"qos_dir": _(qos_dir.capitalize())},
)
return _back(request, pk, "network")
@ -2234,13 +2235,13 @@ def unset_qos(request, pk):
if instance.proxy.get_status() == 5:
messages.success(
request, _("%(qos_dir)s QoS is deleted") % {"qos_dir": qos_dir.capitalize()}
request, _("%(qos_dir)s QoS is deleted") % {"qos_dir": _(qos_dir.capitalize())}
)
else:
messages.success(
request,
_("%(qos_dir)s QoS is deleted. A running VM keeps the old limit until it is powered off and on.")
% {"qos_dir": qos_dir.capitalize()},
% {"qos_dir": _(qos_dir.capitalize())},
)
return _back(request, pk, "network")
@ -2492,9 +2493,9 @@ def create_instance_select_type(request, compute_id):
# the create form's disk sources: (value, title, help)
DISK_SOURCES = [
("new", _("New disk"), _("An empty disk of the size you set")),
("existing", _("Existing volumes"), _("Volumes already in a pool")),
("template", _("Clone from template"), _("A copy of a template volume")),
("new", gettext_lazy("New disk"), gettext_lazy("An empty disk of the size you set")),
("existing", gettext_lazy("Existing volumes"), gettext_lazy("Volumes already in a pool")),
("template", gettext_lazy("Clone from template"), gettext_lazy("A copy of a template volume")),
]

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -288,6 +288,7 @@
</thead>
<tbody>
{% for direction, limit in qos.items %}
{% if direction == "inbound" %}{% trans "inbound" as direction_name %}{% else %}{% trans "outbound" as direction_name %}{% endif %}
<tr>
<td>{% if direction == "inbound" %}{% trans "Inbound" %}{% else %}{% trans "Outbound" %}{% endif %}</td>
<td>{% if limit.average %}{{ limit.average }} KiB/s{% else %}–{% endif %}</td>
@ -301,7 +302,7 @@
<form method="post" class="btn-group">{% csrf_token %}
<input type="hidden" name="qos_direction" value="{{ direction }}">
<button type="submit" class="btn btn-sm btn-outline-secondary" name="unset_qos" aria-label="{% trans "Remove" %}"
data-confirm="{% blocktrans with name=pool %}Remove the {{ direction }} limit of network {{ name }}? It stays in force until the network is stopped and started.{% endblocktrans %}">{% bs_icon 'trash' %}</button>
data-confirm="{% blocktrans with direction=direction_name name=pool %}Remove the {{ direction }} limit of network {{ name }}? It stays in force until the network is stopped and started.{% endblocktrans %}">{% bs_icon 'trash' %}</button>
</form>
</div>
</td>

View file

@ -221,12 +221,12 @@ def network(request, compute_id, pool):
_(
"%(qos_dir)s QoS is updated. Network XML is changed. Stop and start network to activate new config"
)
% {"qos_dir": qos_dir.capitalize()},
% {"qos_dir": _(qos_dir.capitalize())},
)
else:
messages.success(
request,
_("%(qos_dir)s QoS is set") % {"qos_dir": qos_dir.capitalize()},
_("%(qos_dir)s QoS is set") % {"qos_dir": _(qos_dir.capitalize())},
)
except (libvirtError, ValueError) as err:
messages.error(request, err)
@ -247,12 +247,12 @@ def network(request, compute_id, pool):
"%(qos_dir)s QoS is deleted. Network XML is changed. "
"Stop and start network to activate new config"
)
% {"qos_dir": qos_dir.capitalize()},
% {"qos_dir": _(qos_dir.capitalize())},
)
else:
messages.success(
request,
_("%(qos_dir)s QoS is deleted") % {"qos_dir": qos_dir.capitalize()},
_("%(qos_dir)s QoS is deleted") % {"qos_dir": _(qos_dir.capitalize())},
)
return HttpResponseRedirect(here)
# read only for the page: every action above redirects

View file

@ -16,6 +16,18 @@ from operations.templatetags.operations import STATE_COLORS, STATE_LABELS, state
from operations.tests import fakes
class BusyMessageTestCase(SimpleTestCase):
def test_the_state_of_the_job_in_the_way_is_named_in_the_users_language(self):
from types import SimpleNamespace
from operations.utils import busy_message
op = Operation(kind="snapshot.create", state=Operation.RUNNING, created_at=django_timezone.now())
with translation.override("tr"):
text = busy_message(SimpleNamespace(blocker=op), SimpleNamespace(is_superuser=False, pk=1))
self.assertIn(" çalışıyor ", text)
self.assertNotIn("running", text)
class StateColorsTestCase(SimpleTestCase):
def test_every_state_has_its_colour(self):
# job_panel.test.js checks the same map in job_panel.js

View file

@ -12,6 +12,7 @@ import libvirt
from django.utils import timezone
from django.utils.timesince import timesince
from django.utils.translation import gettext as _
from django.utils.translation import pgettext
from django.utils.translation import gettext_lazy
from libvirt import libvirtError
@ -117,7 +118,7 @@ def busy_message(busy, user):
op = busy.blocker
if op is None:
return _("A short operation is in progress on this resource; try again in a moment.")
values = {"label": _(jobs.label_of(op.kind)), "state": op.state, "since": timesince(op.created_at, timezone.now())}
values = {"label": _(jobs.label_of(op.kind)), "state": pgettext("job state", op.state), "since": timesince(op.created_at, timezone.now())}
if may_see(op, user):
return _("%(label)s is %(state)s on this resource (started by %(user)s, %(since)s ago); "
"see the Jobs page.") % {**values, "user": op.username}