1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 03:36:48 +00:00

feat(instances): a VM's owners with their rights, and XML edits kept until saved

Settings > Users was a bold title, an unlabelled "+" and a table of
names with a trash icon. The owner chose this layout from three
rendered options: one list of owners, each with what it may do
(change settings, delete the VM, change console settings, or power and
console only), Edit rights and Remove, an empty state, and a labelled
"Add owner".

- Remove and Edit rights come back to the VM's Users tab: the return
  address carried #users unencoded, so it was lost, and Edit rights
  went to the user's account page. Both accounts views now go to a
  ?next page of this site.
- The add-owner list offers only users who are not owners yet and not
  administrators, who can use every VM anyway; a superuser owner also
  took the only place when a VM may have one owner.
- The unused del_owner view and URL are removed: the list uses the
  accounts page, which asks before removing an owner.

XML:

- An XML edit not saved yet is kept: the page no longer reloads itself
  when the VM's state changes, and the browser asks before leaving it.
  A state change lost the edit.
- The button that opens the review dialog reads "Review changes"; it
  did not save, as "Change" suggested. Closing the dialog gives focus
  back to the editor.
This commit is contained in:
catborise 2026-10-30 17:02:38 +03:00
parent 1150b941be
commit 3d632f1592
12 changed files with 125 additions and 85 deletions

View file

@ -311,3 +311,22 @@ class SingleOwnerEditTests(TestCase):
self.assertRedirects(response, reverse("accounts:account", args=[bob.pk]), fetch_redirect_response=False)
ui.refresh_from_db()
self.assertTrue(ui.is_vnc)
class OwnerRightsReturnTestCase(TestCase):
"""Edit rights on a VM's Users tab comes back to that tab; a page of
another site does not count."""
def test_an_owner_s_rights_come_back_to_the_vm_page(self):
admin = get_user_model().objects.create_superuser("rights-admin", "rights@example.com", "x")
owner = get_user_model().objects.create_user("rights-owner")
compute = Compute.objects.create(name="rights", hostname="192.0.2.30", login="u", password="p", type=1)
vm = Instance.objects.create(compute=compute, name="rights-vm", uuid="12121212-0000-0000-0000-000000000000")
user_instance = UserInstance.objects.create(user=owner, instance=vm)
self.client.force_login(admin)
update = reverse("accounts:user_instance_update", args=[user_instance.id])
for next_url, back in ((f"/instances/{vm.id}/%23users", f"/instances/{vm.id}/#users"),
("https://example.com/", reverse("accounts:account", args=[owner.id]))):
with self.subTest(next_url):
response = self.client.post(f"{update}?next={next_url}", {"is_change": True})
self.assertRedirects(response, back, fetch_redirect_response=False)

View file

@ -235,6 +235,17 @@ def user_instance_create(request, user_id):
)
def _next_or(request, default):
"""To the ?next page, the VM page an owner was changed from, when it is
on this site; else to default."""
next_url = request.GET.get("next")
if next_url and url_has_allowed_host_and_scheme(
url=next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()
):
return redirect(next_url)
return redirect(default)
@superuser_only
def user_instance_update(request, pk):
user_instance = get_object_or_404(UserInstance, pk=pk)
@ -246,7 +257,7 @@ def user_instance_update(request, pk):
fields = changed_fields(before, snapshot(user_instance))
entry.action = f"change owner {user_instance.user.username}: " + ", ".join(fields)
entry.skip = not fields
return redirect(reverse("accounts:account", args=[user_instance.user.id]))
return _next_or(request, reverse("accounts:account", args=[user_instance.user.id]))
return render(
request,
@ -265,14 +276,7 @@ def user_instance_delete(request, pk):
user, instance = user_instance.user, user_instance.instance
with audit_vm(request, instance, f"remove owner {user.username}"):
user_instance.delete()
next_url = request.GET.get("next", None)
if next_url and url_has_allowed_host_and_scheme(
url=next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return redirect(next_url)
return redirect(reverse("accounts:account", args=[user.id]))
return _next_or(request, reverse("accounts:account", args=[user.id]))
return render(
request,

View file

@ -1,35 +1,34 @@
{% load i18n %}
{% load bootstrap_icons %}
{% if request.user.is_superuser %}
<a href="#addInstanceOwner" type="button" class="btn btn-primary float-end" data-bs-toggle="modal">
<span aria-hidden="true">{% bs_icon 'plus'%} </span>
</a>
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addInstanceOwner">{% bs_icon 'plus' %} {% trans "Add owner" %}</button>
<!-- Modal pool -->
<div class="modal fade" id="addInstanceOwner" tabindex="-1" role="dialog" aria-labelledby="addInstanceOwnerLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Add Instance Owner" %}</h5>
<h5 class="modal-title" id="addInstanceOwnerLabel">{% trans "Add owner" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="add-owner-form" method="post" action="{% url 'instances:add_owner' instance.id %}" role="form" aria-label="Add instance owner form">{% csrf_token %}
<div class="row">
<label class="col-sm-4 col-form-label">{% trans "User" %}</label>
<div class="col-sm-6">
<select class="form-select" name="user_id">
{% for user in users %}
<option value="{{ user.id }}">{{ user.username }}</option>
{% endfor %}
</select>
</div>
</div>
{# users who are not owners yet; a superuser can use every VM #}
{% if users %}
<label class="form-label" for="add-owner-user">{% trans "User" %}</label>
<select class="form-select" name="user_id" id="add-owner-user">
{% for user in users %}
<option value="{{ user.id }}">{{ user.username }}</option>
{% endfor %}
</select>
{% else %}
<p class="mb-0">{% trans "Every user who is not an administrator is already an owner." %}</p>
{% endif %}
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
<button type="submit" class="btn btn-primary" form="add-owner-form">{% trans "Add" %}</button>
<button type="submit" class="btn btn-primary" form="add-owner-form"{% if not users %} disabled{% endif %}>{% trans "Add" %}</button>
</div>
</div> <!-- /.modal-content -->
</div> <!-- /.modal-dialog -->

View file

@ -344,7 +344,8 @@
var status_poll = pollWhileVisible(5000, function () {
return $.getJSON("{% url 'instances:status' instance.id %}");
}, function get_status(data) {
if (data['status'] != vm_status && !backgroundJobRunning) {
// an XML edit not saved yet stays: the reload waits until it is saved or undone
if (data['status'] != vm_status && !backgroundJobRunning && !(window.xmlEditUnsaved && window.xmlEditUnsaved())) {
window.location.reload()
}
});

View file

@ -581,16 +581,14 @@
</div>
<div role="tabpanel" class="tab-pane" id="xmledit">
{% if instance.status != 5 %}
<p id="xml-next-start">{% trans "This is the definition for the VM's next start; a saved change applies then." %}</p>
<p class="small text-body-secondary" id="xml-next-start">{% trans "This is the definition for the VM's next start; a saved change applies then." %}</p>
{% endif %}
<form action="{% url 'instances:change_xml' instance.id %}" method="post" role="form" aria-label="Edit instance XML form" id="xml-form">
{% csrf_token %}
<div class="col-sm-12 xmlheight">
<textarea id="editor">{{ instance.inst_xml }}</textarea>
</div>
<button type="submit" class="btn btn-primary float-end" name="change_xml">
{% trans "Change" %}
</button>
<button type="submit" class="btn btn-primary float-end" name="change_xml">{% trans "Review changes" %}</button>
</form>
<div class="clearfix"></div>
<div class="modal fade" id="xml-review" tabindex="-1" aria-labelledby="xml-review-title" aria-hidden="true"
@ -615,29 +613,41 @@
</div>
</div>
<div role="tabpanel" class="tab-pane" id="users">
<div>
<div class="mb-3">
<span class="fw-bold">{% trans "Instance owners" %}</span>
{% include 'add_instance_owner_block.html' %}
</div>
</div>
<div class="table-responsive">
<table class="table table-striped mt-3 align-middle">
<tbody class="searchable">
<section aria-labelledby="owners-heading">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
<h2 class="h6 mb-0" id="owners-heading">{% trans "Instance owners" %}</h2>
{% include 'add_instance_owner_block.html' %}
</div>
<p class="small text-body-secondary">{% trans "Every owner can power the VM and open its console. A new owner has no other right until it is given one." %}</p>
{% if userinstances %}
<ul class="list-group">
{% for userinstance in userinstances %}
<tr>
<td><a href="{% url 'accounts:account' userinstance.user.id %}">{{ userinstance.user }}</a></td>
<td style="width:30px;">
<a href="{% url 'accounts:user_instance_delete' userinstance.id %}?next={% url 'instances:instance' instance.id %}#users">
{% bs_icon 'trash' %}
</a>
</td>
</tr>
<li class="list-group-item p-3">
<div class="row g-3 align-items-center">
<div class="col-12 col-md">
<a class="fw-semibold text-break" href="{% url 'accounts:account' userinstance.user.id %}">{{ userinstance.user }}</a>
<p class="small mb-0 mt-1">
{% if userinstance.is_change or userinstance.is_delete %}
{% if userinstance.is_change %}{% trans "Change settings" %}{% endif %}{% if userinstance.is_change and userinstance.is_delete %} · {% endif %}{% if userinstance.is_delete %}{% trans "Delete the VM" %}{% endif %}{% if userinstance.is_change and userinstance.is_vnc %} · {% trans "Change console settings" %}{% endif %}
{% else %}
<span class="text-body-secondary">{% trans "Power and console only" %}</span>
{% endif %}
</p>
</div>
{% if request.user.is_superuser %}
<div class="col-12 col-md-auto d-flex flex-wrap gap-2">
<a class="btn btn-sm btn-secondary" href="{% url 'accounts:user_instance_update' userinstance.id %}?next={% filter urlencode:'' %}{% url 'instances:instance' instance.id %}#users{% endfilter %}">{% trans "Edit rights" %}<span class="visually-hidden"> {{ userinstance.user }}</span></a>
<a class="btn btn-sm btn-danger" href="{% url 'accounts:user_instance_delete' userinstance.id %}?next={% filter urlencode:'' %}{% url 'instances:instance' instance.id %}#users{% endfilter %}">{% trans "Remove" %}<span class="visually-hidden"> {{ userinstance.user }}</span></a>
</div>
{% endif %}
</div>
</li>
{% endfor %}
</tbody>
</table>
</div>
<div class="clearfix"></div>
</ul>
{% else %}
<p class="mb-0">{% trans "The VM has no owner: only administrators can use it." %}</p>
{% endif %}
</section>
</div>
{% endif %}
{% if can_manage_console %}

View file

@ -31,7 +31,7 @@ class VMOwnerRowTests(TestCase):
self.client.post(add, {"user_id": self.carol.pk})
self.assertFalse(UserInstance.objects.filter(user=self.carol).exists())
ui = UserInstance.objects.get(user=self.bob)
self.client.post(reverse("instances:del_owner", args=[self.vm.pk]), {"userinstance": ui.pk})
self.client.post(reverse("accounts:user_instance_delete", args=[ui.pk]))
self.assertEqual(rows(), [
("VM vm: add owner bob", True),
("VM vm: add owner carol: only one owner is allowed", False),

View file

@ -3,7 +3,6 @@
import inspect
from unittest.mock import patch
from accounts.models import UserInstance
from computes.models import Compute
from django.contrib.auth import get_user_model
from django.contrib.messages import get_messages
@ -49,8 +48,6 @@ class ErrorHandlingTestCase(TestCase):
# Required fields for the views that 404 without them
DATA = {
"add_owner": lambda self: {"user_id": get_user_model().objects.get(username="err_admin").id},
"del_owner": lambda self: {"userinstance": UserInstance.objects.get_or_create(
instance=self.instance, user=get_user_model().objects.get(username="err_admin"))[0].id},
"add_network": lambda self: {"add-net-network": "net:default"},
"set_qos": lambda self: {"qos_direction": "inbound", "net-mac-0": "52:54:00:00:00:01"},
}
@ -68,13 +65,8 @@ class ErrorHandlingTestCase(TestCase):
self.assertEqual(response.status_code, 302)
self.assertTrue(response.url.startswith(self.page + "#"), response.url)
def test_owner_views_reject_missing_or_foreign_ids(self):
other = Instance.objects.create(compute=self.compute, name="other", uuid="11111111-2222-3333-4444-777777777777")
foreign = UserInstance.objects.create(instance=other, user=get_user_model().objects.get(username="err_admin"))
def test_add_owner_rejects_a_missing_user(self):
self.assertEqual(self.client.post(self.url("add_owner"), {}).status_code, 404)
self.assertEqual(self.client.post(self.url("del_owner"), {}).status_code, 404)
self.assertEqual(self.client.post(self.url("del_owner"), {"userinstance": foreign.id}).status_code, 404)
self.assertTrue(UserInstance.objects.filter(pk=foreign.id).exists())
def test_same_site_referer_is_kept(self):
with patch("instances.models.wvmInstance"):

View file

@ -3,6 +3,7 @@ until the next start. The edit forms show that definition, so a save does
not post the running VM's value back over a pending change."""
from unittest.mock import PropertyMock, patch
from urllib.parse import unquote
from computes.models import Compute
from django.contrib.auth import get_user_model
@ -150,6 +151,23 @@ class PendingEditsTestCase(TestCase):
self.assertEqual(doc.xpath("//input[@name='rng']/@checked"), ["checked"])
self.assertEqual(doc.xpath("//select[@name='on_crash']/option[@selected]/@value"), ["restart"])
def test_the_users_tab_shows_each_owner_and_offers_only_new_owners(self):
from accounts.models import UserInstance
User = get_user_model()
owner = User.objects.create_user("pe-owner")
UserInstance.objects.create(instance=self.instance, user=owner, is_change=True, is_vnc=True)
User.objects.create_user("pe-new")
User.objects.create_superuser("pe-admin2", "pe2@example.com", "x")
doc = self.page()
(row,) = doc.xpath("//div[@id='users']//li")
self.assertIn("Change settings · Change console settings", " ".join(row.text_content().split()))
# Remove and Edit rights come back to the Users tab
self.assertEqual([unquote(href.split("?next=")[1]) for href in row.xpath(".//a[contains(@class, 'btn')]/@href")],
[f"/instances/{self.instance.id}/#users"] * 2)
# not an owner yet and not an administrator, who can use every VM anyway
self.assertEqual(doc.xpath("//select[@id='add-owner-user']/option/text()"), ["pe-new"])
def test_buttons_shown_as_disabled_cannot_submit(self):
# running: the CPU resize, vCPU hot plug, template and disk removal forms wait for a shutdown;
# the title, description and video model are of the next start, saved at once

View file

@ -643,11 +643,7 @@ class InstancesTestCase(TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(UserInstance.objects.count(), 1)
response = self.client.post(
reverse("instances:del_owner", args=[self.instance.id]),
{"userinstance": user_instance.id},
HTTP_REFERER=reverse("index"),
)
response = self.client.post(reverse("accounts:user_instance_delete", args=[user_instance.id]))
self.assertEqual(response.status_code, 302)
self.assertEqual(UserInstance.objects.count(), 0)

View file

@ -68,10 +68,7 @@ urlpatterns = [
path("<int:pk>/delete_pci_hostdev/", views.delete_pci_hostdev, name="delete_pci_hostdev"),
path("<int:pk>/set_link_state/", views.set_link_state, name="set_link_state"),
path("<int:pk>/set_qos/", views.set_qos, name="set_qos"),
path("<int:pk>/unset_qos/", views.unset_qos, name="unset_qos"),
path(
"<int:pk>/del_owner/", views.del_owner, name="del_owner"
), # no links to this one???
path("<int:pk>/unset_qos/", views.unset_qos, name="unset_qos"), # no links to this one???
path("<int:pk>/clone/", views.clone, name="clone"),
path("<int:pk>/update_console/", views.update_console, name="update_console"),
path("<int:pk>/change_options/", views.change_options, name="change_options"),

View file

@ -239,7 +239,8 @@ def instance(request, pk):
def _instance_page(request, pk, instance, compute):
computes = Compute.objects.all().order_by("name")
computes_count = computes.count()
users = User.objects.all().order_by("username")
# the add-owner choices: users who are not owners yet; a superuser can use every VM
users = User.objects.filter(is_superuser=False).exclude(userinstance__instance=instance).order_by("username")
publickeys = UserSSHKey.objects.filter(user_id=request.user.id)
keymaps = settings.QEMU_KEYMAPS
# The console form renders the VNC password, so only users who may manage
@ -2096,19 +2097,6 @@ def add_owner(request, pk):
return _back(request, pk, "users")
@require_POST
@superuser_only
def del_owner(request, pk):
instance = get_instance(request.user, pk)
userinstance_id = request.POST.get("userinstance", "")
if not userinstance_id.isdigit():
raise Http404
userinstance = get_object_or_404(UserInstance, pk=userinstance_id, instance=instance)
with audit_vm(request, instance, f"remove owner {userinstance.user.username}"):
userinstance.delete()
return _back(request, pk, "users")
# Buses for the empty CD-ROM of a new VM: fdc and virtio have no ejectable
# media, and libvirt refuses an empty usb disk.
CDROM_BUSES = ("ide", "sata", "scsi")

View file

@ -1,6 +1,8 @@
// The VM page's XML editor. Change shows the lines the edit changes in the
// VM's definition (instances.views.change_xml), and Save in that dialog
// The VM page's XML editor. Review changes shows the lines the edit changes
// in the VM's definition (instances.views.change_xml), and Save in that dialog
// defines it. A refusal shows in the dialog, and the edit stays in the editor.
// An edit not saved yet keeps the page from reloading itself on a VM state
// change (window.xmlEditUnsaved), and the browser asks before leaving it.
(function () {
var form = document.getElementById("xml-form");
if (!form) {
@ -16,6 +18,19 @@
var diff = document.getElementById("xml-review-diff");
var save = document.getElementById("xml-review-save");
var reviewed = ""; // Save defines the XML the dialog shows, not later edits
var clean = base; // the XML the page has: the opened definition, or the one Save just defined
window.xmlEditUnsaved = function () {
return editor.getValue() !== clean;
};
window.addEventListener("beforeunload", function (event) {
if (window.xmlEditUnsaved()) {
event.preventDefault();
}
});
dialog.addEventListener("hidden.bs.modal", function () {
editor.focus();
});
// The answer: diff, saved, or error (detail when the VM is busy or moved).
// Any other failure, such as a redirect, keeps the edit and says so.
@ -70,6 +85,7 @@
save.disabled = true;
post(reviewed, true).then(function (answer) {
if (answer.saved) {
clean = reviewed;
location.hash = "xmledit";
location.reload();
} else {