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

fix(instances): a refused VM create form comes back with what was entered

When the server refused a VM create submission (a field error, a busy
resource, a reused submission key, an existing VM name, a missing MAC
for a new disk), the page came back on its first tab with the flavor
dialog closed and every entered value gone: the hand-written fields
showed fixed defaults, and the errors sat in a block at the top of the
page, outside #messages and tied to no field.

The view builds one NewVMForm per create form on the page (custom,
template, one per flavor; create_forms), each with its own defaults; a
hidden form_id, which is also the prefix of the form's field ids, names
the submitted one, and it comes back bound. Every field renders from its
own form and is marked by two small template tags (form_fields:
error_attrs, field_error) when refused; a summary at the top of the
form lists all of its errors, also those of fields it keeps hidden. The
submitted tab or flavor dialog, marked data-form-id, reopens with any
"Advanced" section that holds an error; a reopened dialog shows the
messages too; the template form gets its pool, template and storage
back. base.html's error focus is a function that walks the places an
error can be, and a page can call it once it is set up, as the create
page does. form_id changes nothing in how a POST is processed; one of
an unknown form (a flavor deleted since) has its errors in the
messages. The default video now applies to every create form.

The custom form's disks and networks are not restored yet.
This commit is contained in:
catborise 2026-10-19 23:13:25 +03:00
parent 35468bc0fd
commit dba41f97f3
9 changed files with 640 additions and 164 deletions

View file

@ -2,6 +2,9 @@
{% load i18n %}
{% load static %}
{% load operations %}
{% load django_bootstrap5 %}
{% load form_fields %}
{% block body_attrs %} data-error-focus="manual" data-submitted-form="{{ submitted_form }}"{% endblock %}
{% load bootstrap_icons %}
{% block title %}{% trans "Create new instance" %}{% endblock %}
@ -20,20 +23,6 @@
{% block content %}
{% include 'pleasewaitdialog.html' %}
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<div class="alert alert-danger">
<strong>{{ field.label }}:</strong> <span>{{ error|escape }}</span>
</div>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<div class="alert alert-danger">
<strong>{{ error|escape }}</strong>
</div>
{% endfor %}
{% endif %}
<div class="row">
<div class="col-lg-12">
@ -86,7 +75,7 @@
</tr>
</thead>
<tbody>
{% for flavor in flavors %}
{% for flavor, form_id, vm_form in flavor_forms %}
<tr>
<td>{{ forloop.counter }}</td>
<td>{{ flavor.label }}</td>
@ -94,7 +83,7 @@
<td>{{ flavor.memory }} {% trans "MB" %}</td>
<td>{{ flavor.disk }} {% trans "GB" %}</td>
<td style="width:5px;">
<div class="modal fade" id="addVMflavor{{ forloop.counter }}" tabindex="-1" role="dialog" aria-labelledby="addVMFlavorLabel" aria-hidden="true">
<div class="modal fade" id="addVMflavor{{ flavor.id }}" data-form-id="{{ form_id }}" tabindex="-1" role="dialog" aria-labelledby="addVMFlavorLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@ -102,76 +91,87 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" role="form" aria-label="Create instance from flavor form">{% csrf_token %}{% submission_key %}
<input type="hidden" name="form_id" value="{{ form_id }}">
<div class="modal-body">
{% if submitted_form == form_id %}<div class="modal-messages">{% bootstrap_messages %}</div>{% endif %}
{% include "instances/form_errors.html" with form=vm_form %}
<div class="row">
<label for="flavor{{ flavor.id }}_name" class="col-sm-4 col-form-label">{% trans "Name" %}</label>
<label for="{{ form_id }}_name" class="col-sm-4 col-form-label">{% trans "Name" %}</label>
<div class="col-sm-7">
<input id="flavor{{ flavor.id }}_name" type="text" class="form-control" name="name"
<input id="{{ form_id }}_name" type="text" class="form-control{{ vm_form.name|invalid }}" name="name" value="{{ vm_form.name.value|default_if_none:"" }}"
placeholder="{% trans "Name" %}" maxlength="64" required
pattern="[a-zA-Z0-9\.\-_]+">
<input type="hidden" name="vcpu" value="{{ flavor.vcpu }}">
<input type="hidden" name="memory" value="{{ flavor.memory }}">
<input type="hidden" name="hdd_size" value="{{ flavor.disk }}">
pattern="[a-zA-Z0-9\.\-_]+"{% error_attrs vm_form.name form_id|add:"_name" %}>
{% field_error vm_form.name form_id|add:"_name" %}
<input type="hidden" name="vcpu" value="{{ vm_form.vcpu.value|default_if_none:"" }}">
<input type="hidden" name="memory" value="{{ vm_form.memory.value|default_if_none:"" }}">
<input type="hidden" name="hdd_size" value="{{ vm_form.hdd_size.value|default_if_none:"" }}">
</div>
</div>
{% if firmwares %}
<div class="row">
<label for="flavor{{ flavor.id }}_firmware" class="col-sm-4 col-form-label">{% trans "Firmware" %}</label>
<label for="{{ form_id }}_firmware" class="col-sm-4 col-form-label">{% trans "Firmware" %}</label>
<div class="col-sm-7">
<select id="flavor{{ flavor.id }}_firmware" class="form-select" name="firmware">
<select id="{{ form_id }}_firmware" class="form-select{{ vm_form.firmware|invalid }}" name="firmware"
{% error_attrs vm_form.firmware form_id|add:"_firmware" %}>
{% for frm in firmwares %}
<option value="{{ frm }}" {% if frm in default_firmware %}selected{% endif %}>{{ frm }}</option>
<option value="{{ frm }}" {% if frm == vm_form.firmware.value %}selected{% endif %}>{{ frm }}</option>
{% endfor %}
</select>
{% field_error vm_form.firmware form_id|add:"_firmware" %}
</div>
</div>
{% endif %}
{% if dom_caps.cpu_modes %}
<div class="row">
<label for="flavor{{ flavor.id }}_vcpu_mode" class="col-sm-4 col-form-label">{% trans "VCPU Config" %}</label>
<label for="{{ form_id }}_vcpu_mode" class="col-sm-4 col-form-label">{% trans "VCPU Config" %}</label>
<div class="col-sm-7">
<select id="flavor{{ flavor.id }}_vcpu_mode" class="form-select" name="vcpu_mode">
<select id="{{ form_id }}_vcpu_mode" class="form-select{{ vm_form.vcpu_mode|invalid }}" name="vcpu_mode"
{% error_attrs vm_form.vcpu_mode form_id|add:"_vcpu_mode" %}>
<option value=""> {% trans 'no-mode' %}</option>
{% for mode in dom_caps.cpu_modes %}
{% if mode == 'custom' %}
<optgroup label="Custom CPU Models">
{% for model in dom_caps.cpu_custom_models %}
<option value="{{ model }}"> {% trans model %}</option>
<option value="{{ model }}" {% if model == vm_form.vcpu_mode.value %}selected{% endif %}> {% trans model %}</option>
{% endfor %}
</optgroup>
{% else %}
<option value="{{ mode }}" {% if mode == default_cpu_mode %}selected {% endif %}>
<option value="{{ mode }}" {% if mode == vm_form.vcpu_mode.value %}selected {% endif %}>
{% trans mode %}
</option>
{% endif %}
{% endfor %}
</select>
{% field_error vm_form.vcpu_mode form_id|add:"_vcpu_mode" %}
</div>
</div>
{% endif %}
<div class="row">
<label for="flavor{{ flavor.id }}_storage" class="col-sm-4 col-form-label">{% trans "Storage" %}</label>
<label for="{{ form_id }}_storage" class="col-sm-4 col-form-label">{% trans "Storage" %}</label>
<div class="col-sm-7">
<input type="hidden" name="cache_mode" value="default">
<select id="flavor{{ flavor.id }}_storage" class="form-select" name="storage">
<select id="{{ form_id }}_storage" class="form-select{{ vm_form.storage|invalid }}" name="storage"
{% error_attrs vm_form.storage form_id|add:"_storage" %}>
{% if storages %}
{% for storage in storages %}
<option value="{{ storage }}">{{ storage }}</option>
<option value="{{ storage }}" {% if storage == vm_form.storage.value %}selected{% endif %}>{{ storage }}</option>
{% endfor %}
{% else %}
<option value="">{% trans "None" %}</option>
{% endif %}
</select>
{% field_error vm_form.storage form_id|add:"_storage" %}
</div>
</div>
<div class="row">
<label for="flavor{{ flavor.id }}_networks" class="col-sm-4 col-form-label">{% trans "Network" %}</label>
<label for="{{ form_id }}_networks" class="col-sm-4 col-form-label">{% trans "Network" %}</label>
<div class="col-sm-7">
<select id="flavor{{ flavor.id }}_networks" class="form-select" name="networks">
<select id="{{ form_id }}_networks" class="form-select{{ vm_form.networks|invalid }}" name="networks"
{% error_attrs vm_form.networks form_id|add:"_networks" %}>
{% for network in networks %}
<option value="{{ network }}">{{ network }}</option>
<option value="{{ network }}" {% if network == vm_form.networks.value %}selected{% endif %}>{{ network }}</option>
{% endfor %}
</select>
{% field_error vm_form.networks form_id|add:"_networks" %}
</div>
</div>
<div class="row">
@ -184,104 +184,121 @@
</div>
<div class="collapse" id="flav-add-collapse-{{ flavor.id }}">
<div class="row">
<label for="flavor{{ flavor.id }}_mac" class="col-sm-4 col-form-label">{% trans "MAC" %}</label>
<label for="{{ form_id }}_mac" class="col-sm-4 col-form-label">{% trans "MAC" %}</label>
<div class="col-sm-7">
<input id="flavor{{ flavor.id }}_mac" type="text" class="form-control" name="mac" maxlength="17" value="{{ mac_auto }}" required pattern="[a-zA-Z0-9:]+">
<input id="{{ form_id }}_mac" type="text" class="form-control{{ vm_form.mac|invalid }}" name="mac"
value="{{ vm_form.mac.value|default_if_none:"" }}" maxlength="17" required pattern="[a-zA-Z0-9:]+"
{% error_attrs vm_form.mac form_id|add:"_mac" %}>
{% field_error vm_form.mac form_id|add:"_mac" %}
</div>
</div>
<div class="row">
<label for="flavor{{ flavor.id }}_nwfilter" class="col-sm-4 col-form-label">{% trans "NWFilter" %}</label>
<label for="{{ form_id }}_nwfilter" class="col-sm-4 col-form-label">{% trans "NWFilter" %}</label>
<div class="col-sm-7">
<select id="flavor{{ flavor.id }}_nwfilter" class="form-select" name="nwfilter">
<select id="{{ form_id }}_nwfilter" class="form-select{{ vm_form.nwfilter|invalid }}" name="nwfilter"
{% error_attrs vm_form.nwfilter form_id|add:"_nwfilter" %}>
<option value="">{% trans "None" %}</option>
{% for nwfilter in nwfilters %}
<option value="{{ nwfilter }}">{{ nwfilter }}</option>
<option value="{{ nwfilter }}" {% if nwfilter == vm_form.nwfilter.value %}selected{% endif %}>{{ nwfilter }}</option>
{% endfor %}
</select>
{% field_error vm_form.nwfilter form_id|add:"_nwfilter" %}
</div>
</div>
<div class="row">
<label for="flavor{{ flavor.id }}_net_model" class="col-sm-4 col-form-label">{% trans "NIC Type" %} </label>
<label for="{{ form_id }}_net_model" class="col-sm-4 col-form-label">{% trans "NIC Type" %} </label>
<div class="col-sm-7">
<select id="flavor{{ flavor.id }}_net_model" class="form-select" name="net_model">
<select id="{{ form_id }}_net_model" class="form-select{{ vm_form.net_model|invalid }}" name="net_model"
{% error_attrs vm_form.net_model form_id|add:"_net_model" %}>
{% for model in net_models_host %}
<option value="{{ model }}" {% if model == default_nic_type %} selected {% endif %}>{{ model }}</option>
<option value="{{ model }}" {% if model == vm_form.net_model.value %} selected {% endif %}>{{ model }}</option>
{% endfor %}
</select>
{% field_error vm_form.net_model form_id|add:"_net_model" %}
</div>
</div>
<div class="row">
<label for="flavor{{ flavor.id }}_cache_mode" class="col-sm-4 col-form-label">{% trans "HDD cache mode" %}</label>
<label for="{{ form_id }}_cache_mode" class="col-sm-4 col-form-label">{% trans "HDD cache mode" %}</label>
<div class="col-sm-7">
<select id="flavor{{ flavor.id }}_cache_mode" class="form-select" name="cache_mode">
<select id="{{ form_id }}_cache_mode" class="form-select{{ vm_form.cache_mode|invalid }}" name="cache_mode"
{% error_attrs vm_form.cache_mode form_id|add:"_cache_mode" %}>
{% for mode, name in cache_modes %}
<option value="{{ mode }}" {% if mode == default_cache %}selected {% endif %}>
<option value="{{ mode }}" {% if mode == vm_form.cache_mode.value %}selected {% endif %}>
{% trans name %}</option>
{% endfor %}
</select>
{% field_error vm_form.cache_mode form_id|add:"_cache_mode" %}
</div>
</div>
<div class="row">
<label for="flavor{{ flavor.id }}_video" class="col-sm-4 col-form-label">{% trans "Video" %}</label>
<label for="{{ form_id }}_video" class="col-sm-4 col-form-label">{% trans "Video" %}</label>
<div class="col-sm-7">
<select id="flavor{{ flavor.id }}_video" class="form-select" name="video">
<select id="{{ form_id }}_video" class="form-select{{ vm_form.video|invalid }}" name="video"
{% error_attrs vm_form.video form_id|add:"_video" %}>
{% if not videos %}
<option value="vga">vga</option>
<option value="cirrus">cirrus</option>
{% endif %}
{% for video in videos %}
<option value="{{ video }}" {% if default_video == video %}selected{% endif %}>{{ video }}</option>
<option value="{{ video }}" {% if video == vm_form.video.value %}selected{% endif %}>{{ video }}</option>
{% endfor %}
</select>
{% field_error vm_form.video form_id|add:"_video" %}
</div>
</div>
<div class="row">
<label for="flavor{{ flavor.id }}_listener_addr" class="col-sm-4 col-form-label">{% trans "Console Access" %}</label>
<label for="{{ form_id }}_listener_addr" class="col-sm-4 col-form-label">{% trans "Console Access" %}</label>
<div class="col-sm-7">
<select id="flavor{{ flavor.id }}_listener_addr" class="form-select" name="listener_addr">
<select id="{{ form_id }}_listener_addr" class="form-select{{ vm_form.listener_addr|invalid }}" name="listener_addr"
{% error_attrs vm_form.listener_addr form_id|add:"_listener_addr" %}>
{% for addr, label in listener_addr %}
<option value="{{ addr }}" {% if addr == "0.0.0.0" %} selected {% endif %}>{{ label }}</option>
<option value="{{ addr }}" {% if addr == vm_form.listener_addr.value %} selected {% endif %}>{{ label }}</option>
{% endfor %}
</select>
{% field_error vm_form.listener_addr form_id|add:"_listener_addr" %}
</div>
</div>
<div class="row">
<label for="flavor{{ flavor.id }}_add_cdrom" class="col-sm-4 col-form-label">{% trans "Add CD-Rom" %}</label>
<label for="{{ form_id }}_add_cdrom" class="col-sm-4 col-form-label">{% trans "Add CD-Rom" %}</label>
<div class="col-sm-7">
<select id="flavor{{ flavor.id }}_add_cdrom" class="form-select" name="add_cdrom">
<option value="None" {% if bus == default_cdrom %}selected{% endif %}>--{% trans "False" %}--</option>
<select id="{{ form_id }}_add_cdrom" class="form-select{{ vm_form.add_cdrom|invalid }}" name="add_cdrom"
{% error_attrs vm_form.add_cdrom form_id|add:"_add_cdrom" %}>
<option value="None" {% if vm_form.add_cdrom.value == "None" %}selected{% endif %}>--{% trans "False" %}--</option>
{% for bus in cdrom_buses %}
<option value="{{ bus }}" {% if bus == default_cdrom %}selected {% endif %}>
<option value="{{ bus }}" {% if bus == vm_form.add_cdrom.value %}selected {% endif %}>
{% trans bus %}
</option>
{% endfor %}
</select>
{% field_error vm_form.add_cdrom form_id|add:"_add_cdrom" %}
</div>
</div>
<div class="row">
<label for="flavor{{ flavor.id }}_add_input" class="col-sm-4 col-form-label">{% trans "Add Input Device" %}</label>
<label for="{{ form_id }}_add_input" class="col-sm-4 col-form-label">{% trans "Add Input Device" %}</label>
<div class="col-sm-7">
<select id="flavor{{ flavor.id }}_add_input" class="form-select" name="add_input">
<option value="None" {% if bus == default_input_device_bus %}selected{% endif %}>--{% trans "False" %}--</option>
<select id="{{ form_id }}_add_input" class="form-select{{ vm_form.add_input|invalid }}" name="add_input"
{% error_attrs vm_form.add_input form_id|add:"_add_input" %}>
<option value="None" {% if vm_form.add_input.value == "None" %}selected{% endif %}>--{% trans "False" %}--</option>
{% for bus in input_device_buses %}
<option value="{{ bus }}" {% if bus == default_input_device_bus %}selected{% endif %}>
<option value="{{ bus }}" {% if bus == vm_form.add_input.value %}selected{% endif %}>
{% trans bus %}
</option>
{% endfor %}
</select>
{% field_error vm_form.add_input form_id|add:"_add_input" %}
</div>
</div>
<div class="row">
<label for="flavor{{ flavor.id }}_qemu_ga" class="col-sm-4 col-form-label">{% trans "Guest Agent" %}</label>
<label for="{{ form_id }}_qemu_ga" class="col-sm-4 col-form-label">{% trans "Guest Agent" %}</label>
<div class="col-sm-7">
<input id="flavor{{ flavor.id }}_qemu_ga" type="checkbox" name="qemu_ga" value="true" checked>
<input id="{{ form_id }}_qemu_ga" type="checkbox" name="qemu_ga" value="true"{% if vm_form.qemu_ga.value %} checked{% endif %}>
</div>
</div>
{% if virtio_support %}
<div class="row">
<label for="flavor{{ flavor.id }}_virtio" class="col-sm-4 col-form-label">{% trans "VirtIO" %}</label>
<label for="{{ form_id }}_virtio" class="col-sm-4 col-form-label">{% trans "VirtIO" %}</label>
<div class="col-sm-7">
<input id="flavor{{ flavor.id }}_virtio" type="checkbox" name="virtio" value="{{ virtio_support }}" checked>
<input id="{{ form_id }}_virtio" type="checkbox" name="virtio" value="{{ virtio_support }}"{% if vm_form.virtio.value %} checked{% endif %}>
</div>
</div>
{% endif %}
@ -299,7 +316,7 @@
</div>
</div>
</div>
<a data-bs-toggle="modal" href="#addVMflavor{{ forloop.counter }}" class="btn btn-sm btn-secondary" title="{% trans "Create Virtual Machine" %}">
<a data-bs-toggle="modal" href="#addVMflavor{{ flavor.id }}" class="btn btn-sm btn-secondary" title="{% trans "Create Virtual Machine" %}">
<span >{% bs_icon 'plus'%} </span>
</a>
</td>
@ -320,60 +337,75 @@
<div class="clearfix"></div>
</div>
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="addCustom">
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="addCustom" data-form-id="custom">
<div>
<form method="post" role="form" aria-label="Create custom instance form">{% csrf_token %}{% submission_key %}
<input type="hidden" name="form_id" value="custom">
{% include "instances/form_errors.html" with form=custom_form %}
<div class="row">
<label for="custom_name" class="col-sm-3 col-form-label">{% trans "Name" %}</label>
<div class="col-sm-7">
<input id="custom_name" type="text" class="form-control" name="name" placeholder="{% trans "Name" %}" maxlength="64" required pattern="[a-zA-Z0-9\.\-_]+">
<input id="custom_name" type="text" class="form-control{{ custom_form.name|invalid }}" name="name"
value="{{ custom_form.name.value|default_if_none:"" }}" placeholder="{% trans "Name" %}" maxlength="64" required pattern="[a-zA-Z0-9\.\-_]+"
{% error_attrs custom_form.name "custom_name" %}>
{% field_error custom_form.name "custom_name" %}
</div>
</div>
{% if firmwares %}
<div class="row">
<label for="select_firmware" class="col-sm-3 col-form-label">{% trans "Firmware" %}</label>
<div class="col-sm-7">
<select class="form-select" id="select_firmware" name="firmware">
<select class="form-select{{ custom_form.firmware|invalid }}" id="select_firmware" name="firmware"
{% error_attrs custom_form.firmware "select_firmware" %}>
{% for frm in firmwares %}
<option value="{{ frm }}" {% if frm == default_firmware %}selected{% endif %}>{{ frm }}</option>
<option value="{{ frm }}" {% if frm == custom_form.firmware.value %}selected{% endif %}>{{ frm }}</option>
{% endfor %}
</select>
{% field_error custom_form.firmware "select_firmware" %}
</div>
</div>
{% endif %}
<div class="row">
<label for="custom_vcpu" class="col-sm-3 col-form-label">{% trans "VCPU" %}</label>
<div class="col-sm-7">
<input id="custom_vcpu" type="text" class="form-control" name="vcpu" value="1" maxlength="2" required pattern="[0-9]">
<input id="custom_vcpu" type="text" class="form-control{{ custom_form.vcpu|invalid }}" name="vcpu"
value="{{ custom_form.vcpu.value|default_if_none:"" }}" maxlength="2" required pattern="[0-9]"
{% error_attrs custom_form.vcpu "custom_vcpu" %}>
{% field_error custom_form.vcpu "custom_vcpu" %}
</div>
</div>
{% if dom_caps.cpu_modes %}
<div class="row">
<label for="custom_vcpu_mode" class="col-sm-3 col-form-label">{% trans "VCPU Config" %}</label>
<div class="col-sm-7">
<select id="custom_vcpu_mode" class="form-select" name="vcpu_mode">
<select id="custom_vcpu_mode" class="form-select{{ custom_form.vcpu_mode|invalid }}" name="vcpu_mode"
{% error_attrs custom_form.vcpu_mode "custom_vcpu_mode" %}>
<option value=""> {% trans 'no-mode' %}</option>
{% for mode in dom_caps.cpu_modes %}
{% if mode == 'custom' %}
<optgroup label="Custom CPU Models">
{% for model in dom_caps.cpu_custom_models %}
<option value="{{ model }}"> {% trans model %}</option>
<option value="{{ model }}" {% if model == custom_form.vcpu_mode.value %}selected{% endif %}> {% trans model %}</option>
{% endfor %}
</optgroup>
{% else %}
<option value="{{ mode }}" {% if mode == default_cpu_mode %}selected {% endif %}>
<option value="{{ mode }}" {% if mode == custom_form.vcpu_mode.value %}selected {% endif %}>
{% trans mode %}
</option>
{% endif %}
{% endfor %}
</select>
{% field_error custom_form.vcpu_mode "custom_vcpu_mode" %}
</div>
</div>
{% endif %}
<div class="row">
<label for="custom_memory" class="col-sm-3 col-form-label">{% trans "RAM" %}</label>
<div class="col-sm-7">
<input id="custom_memory" type="text" class="form-control" name="memory" value="512" maxlength="5" required pattern="[0-9]+">
<input id="custom_memory" type="text" class="form-control{{ custom_form.memory|invalid }}" name="memory"
value="{{ custom_form.memory.value|default_if_none:"" }}" maxlength="5" required pattern="[0-9]+"
{% error_attrs custom_form.memory "custom_memory" %}>
{% field_error custom_form.memory "custom_memory" %}
</div>
<label class="col-sm-1 col-form-label">{% trans "MB" %}</label>
</div>
@ -433,103 +465,117 @@
<div class="row meta-prealloc">
<label for="custom_meta_prealloc" class="col-sm-3 col-form-label">{% trans "Disk Metadata" %}</label>
<div class="col-sm-7">
<input id="custom_meta_prealloc" type="checkbox" name="meta_prealloc" title="{% trans "Metadata preallocation" %}" value="true">
<input id="custom_meta_prealloc" type="checkbox" name="meta_prealloc" title="{% trans "Metadata preallocation" %}" value="true"{% if custom_form.meta_prealloc.value %} checked{% endif %}>
</div>
<label class="col-lg-1 col-form-label">{% trans "Image" %}</label>
</div>
<div class="row">
<label for="cache_mode" class="col-sm-3 col-form-label">{% trans "HDD Cache Mode" %}</label>
<div class="col-sm-7">
<select class="form-select" id="cache_mode" name="cache_mode">
<select class="form-select{{ custom_form.cache_mode|invalid }}" id="cache_mode" name="cache_mode"
{% error_attrs custom_form.cache_mode "cache_mode" %}>
{% for mode, name in cache_modes %}
<option value="{{ mode }}" {% if mode == default_cache %}selected {% endif %}>
<option value="{{ mode }}" {% if mode == custom_form.cache_mode.value %}selected {% endif %}>
{% trans name %}</option>
{% endfor %}
</select>
{% field_error custom_form.cache_mode "cache_mode" %}
</div>
</div>
<div class="row">
<label for="custom_nwfilter" class="col-sm-3 col-form-label">{% trans "NWFilter" %}</label>
<div class="col-sm-7">
<select id="custom_nwfilter" class="form-select" name="nwfilter">
<select id="custom_nwfilter" class="form-select{{ custom_form.nwfilter|invalid }}" name="nwfilter"
{% error_attrs custom_form.nwfilter "custom_nwfilter" %}>
<option value="">{% trans "None" %}</option>
{% for nwfilter in nwfilters %}
<option value="{{ nwfilter }}">{{ nwfilter }}</option>
<option value="{{ nwfilter }}" {% if nwfilter == custom_form.nwfilter.value %}selected{% endif %}>{{ nwfilter }}</option>
{% endfor %}
</select>
{% field_error custom_form.nwfilter "custom_nwfilter" %}
</div>
</div>
<div class="row">
<label for="custom_net_model" class="col-sm-3 col-form-label">{% trans "NIC Type" %} </label>
<div class="col-sm-7">
<select id="custom_net_model" class="form-select" name="net_model">
<select id="custom_net_model" class="form-select{{ custom_form.net_model|invalid }}" name="net_model"
{% error_attrs custom_form.net_model "custom_net_model" %}>
{% for model in net_models_host %}
<option value="{{ model }}" {% if model == default_nic_type %} selected {% endif %}>{{ model }}</option>
<option value="{{ model }}" {% if model == custom_form.net_model.value %} selected {% endif %}>{{ model }}</option>
{% endfor %}
</select>
{% field_error custom_form.net_model "custom_net_model" %}
</div>
</div>
<div class="row">
<label for="custom_video" class="col-sm-3 col-form-label">{% trans "Video" %}</label>
<div class="col-sm-7">
<select id="custom_video" class="form-select" name="video">
<select id="custom_video" class="form-select{{ custom_form.video|invalid }}" name="video"
{% error_attrs custom_form.video "custom_video" %}>
{% if not videos %}
<option value="vga">vga</option>
<option value="cirrus">cirrus</option>
{% endif %}
{% for video in videos %}
<option value="{{ video }}">{{ video }}</option>
<option value="{{ video }}" {% if video == custom_form.video.value %}selected{% endif %}>{{ video }}</option>
{% endfor %}
</select>
{% field_error custom_form.video "custom_video" %}
</div>
</div>
<div class="row">
<label for="custom_listener_addr" class="col-sm-3 col-form-label">{% trans "Console Access" %}</label>
<div class="col-sm-7">
<select id="custom_listener_addr" class="form-select" name="listener_addr">
<select id="custom_listener_addr" class="form-select{{ custom_form.listener_addr|invalid }}" name="listener_addr"
{% error_attrs custom_form.listener_addr "custom_listener_addr" %}>
{% for addr, label in listener_addr %}
<option value="{{ addr }}" {% if addr == "0.0.0.0" %} selected {% endif %}>{{ label }}</option>
<option value="{{ addr }}" {% if addr == custom_form.listener_addr.value %} selected {% endif %}>{{ label }}</option>
{% endfor %}
</select>
{% field_error custom_form.listener_addr "custom_listener_addr" %}
</div>
</div>
<div class="row">
<label for="add_cdrom" class="col-sm-3 col-form-label">{% trans "Add CD-Rom" %}</label>
<div class="col-sm-7">
<select class="form-select" id="add_cdrom" name="add_cdrom">
<option value="None" {% if bus == default_cdrom %}selected{% endif %} >--{% trans "False" %}--</option>
<select class="form-select{{ custom_form.add_cdrom|invalid }}" id="add_cdrom" name="add_cdrom"
{% error_attrs custom_form.add_cdrom "add_cdrom" %}>
<option value="None" {% if custom_form.add_cdrom.value == "None" %}selected{% endif %} >--{% trans "False" %}--</option>
{% for bus in cdrom_buses %}
<option value="{{ bus }}" {% if bus == default_cdrom %}selected {% endif %}>
<option value="{{ bus }}" {% if bus == custom_form.add_cdrom.value %}selected {% endif %}>
{% trans bus %}
</option>
{% endfor %}
</select>
{% field_error custom_form.add_cdrom "add_cdrom" %}
</div>
</div>
<div class="row">
<label for="add_input" class="col-sm-3 col-form-label">{% trans "Add Input Device" %}</label>
<div class="col-sm-7">
<select class="form-select" id="add_input" name="add_input">
<option value="None" {% if bus == default_input_device_bus %}selected{% endif %}>--{% trans "False" %}--</option>
<select class="form-select{{ custom_form.add_input|invalid }}" id="add_input" name="add_input"
{% error_attrs custom_form.add_input "add_input" %}>
<option value="None" {% if custom_form.add_input.value == "None" %}selected{% endif %}>--{% trans "False" %}--</option>
{% for bus in input_device_buses %}
<option value="{{ bus }}" {% if bus == default_input_device_bus %}selected{% endif %}>
<option value="{{ bus }}" {% if bus == custom_form.add_input.value %}selected{% endif %}>
{% trans bus %}
</option>
{% endfor %}
</select>
{% field_error custom_form.add_input "add_input" %}
</div>
</div>
<div class="row">
<label for="custom_qemu_ga" class="col-sm-3 col-form-label">{% trans "Guest Agent" %}</label>
<div class="col-sm-6">
<input id="custom_qemu_ga" type="checkbox" name="qemu_ga" value="true" checked>
<input id="custom_qemu_ga" type="checkbox" name="qemu_ga" value="true"{% if custom_form.qemu_ga.value %} checked{% endif %}>
</div>
</div>
{% if virtio_support %}
<div class="row">
<label for="custom_virtio" class="col-sm-3 col-form-label">{% trans "VirtIO" %}</label>
<div class="col-sm-6">
<input id="custom_virtio" type="checkbox" name="virtio" value="{{ virtio_support }}" checked>
<input id="custom_virtio" type="checkbox" name="virtio" value="{{ virtio_support }}"{% if custom_form.virtio.value %} checked{% endif %}>
</div>
</div>
{% endif %}
@ -552,65 +598,85 @@
<div class="clearfix"></div>
</div>
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="add-from-temp">
<div role="tabpanel" class="tab-pane tab-pane-bordered" id="add-from-temp" data-form-id="template">
<div>
<form method="post" role="form" aria-label="Create instance from template form">{% csrf_token %}{% submission_key %}
<form method="post" role="form" aria-label="Create instance from template form"
{% if submitted_form == "template" %}
data-template-pool="{{ request.POST.template_pool }}"
data-template="{{ template_form.template.value|default_if_none:"" }}"
data-storage="{{ template_form.storage.value|default_if_none:"" }}"
{% endif %}>{% csrf_token %}{% submission_key %}
<input type="hidden" name="form_id" value="template">
{% include "instances/form_errors.html" with form=template_form %}
<div class="row">
<label for="template_name" class="col-sm-3 col-form-label">{% trans "Name" %}</label>
<div class="col-sm-7">
<input id="template_name" type="text" class="form-control" name="name" placeholder="{% trans "Name" %}" maxlength="64" required pattern="[a-zA-Z0-9\.\-_]+">
<input id="template_name" type="text" class="form-control{{ template_form.name|invalid }}" name="name"
value="{{ template_form.name.value|default_if_none:"" }}" placeholder="{% trans "Name" %}" maxlength="64" required pattern="[a-zA-Z0-9\.\-_]+"
{% error_attrs template_form.name "template_name" %}>
{% field_error template_form.name "template_name" %}
</div>
</div>
{% if firmwares %}
<div class="row">
<label for="template_firmware" class="col-sm-3 col-form-label">{% trans "Firmware" %}</label>
<div class="col-sm-7">
<select id="template_firmware" class="form-select" name="firmware">
<select id="template_firmware" class="form-select{{ template_form.firmware|invalid }}" name="firmware"
{% error_attrs template_form.firmware "template_firmware" %}>
{% for frm in firmwares %}
<option value="{{ frm }}" {% if frm == default_firmware %}selected{% endif %}>{{ frm }}</option>
<option value="{{ frm }}" {% if frm == template_form.firmware.value %}selected{% endif %}>{{ frm }}</option>
{% endfor %}
</select>
{% field_error template_form.firmware "template_firmware" %}
</div>
</div>
{% endif %}
<div class="row">
<label for="template_vcpu" class="col-sm-3 col-form-label">{% trans "VCPU" %}</label>
<div class="col-sm-7">
<input id="template_vcpu" type="text" class="form-control" name="vcpu" value="1" maxlength="2" required pattern="[0-9]">
<input id="template_vcpu" type="text" class="form-control{{ template_form.vcpu|invalid }}" name="vcpu"
value="{{ template_form.vcpu.value|default_if_none:"" }}" maxlength="2" required pattern="[0-9]"
{% error_attrs template_form.vcpu "template_vcpu" %}>
{% field_error template_form.vcpu "template_vcpu" %}
</div>
</div>
<div class="row">
<label for="template_vcpu_mode" class="col-sm-3 col-form-label">{% trans "VCPU Config" %}</label>
<div class="col-sm-7">
<select id="template_vcpu_mode" class="form-select" name="vcpu_mode">
<select id="template_vcpu_mode" class="form-select{{ template_form.vcpu_mode|invalid }}" name="vcpu_mode"
{% error_attrs template_form.vcpu_mode "template_vcpu_mode" %}>
<option value=""> {% trans 'no-mode' %}</option>
{% for mode in dom_caps.cpu_modes %}
{% if mode == 'custom' %}
<optgroup label="Custom CPU Models">
{% for model in dom_caps.cpu_custom_models %}
<option value="{{ model }}"> {% trans model %}</option>
<option value="{{ model }}" {% if model == template_form.vcpu_mode.value %}selected{% endif %}> {% trans model %}</option>
{% endfor %}
</optgroup>
{% else %}
<option value="{{ mode }}" {% if mode == default_cpu_mode %}selected{% endif %}>
<option value="{{ mode }}" {% if mode == template_form.vcpu_mode.value %}selected{% endif %}>
{% trans mode %}
</option>
{% endif %}
{% endfor %}
</select>
{% field_error template_form.vcpu_mode "template_vcpu_mode" %}
</div>
</div>
<div class="row">
<label for="template_memory" class="col-sm-3 col-form-label">{% trans "RAM" %}</label>
<div class="col-sm-7">
<input id="template_memory" type="text" class="form-control" name="memory" value="512" maxlength="5" required pattern="[0-9]+">
<input id="template_memory" type="text" class="form-control{{ template_form.memory|invalid }}" name="memory"
value="{{ template_form.memory.value|default_if_none:"" }}" maxlength="5" required pattern="[0-9]+"
{% error_attrs template_form.memory "template_memory" %}>
{% field_error template_form.memory "template_memory" %}
</div>
<label class="col-sm-1 col-form-label">{% trans "MB" %}</label>
</div>
<div class="row">
<label for="template_disk" class="col-sm-3 col-form-label">{% trans "Template Disk" %}</label>
<div class="col-sm-3">
<select class="form-select" id="template_disk" onchange="get_template_vols({{ compute_id }}, value);">
<select class="form-select" id="template_disk" name="template_pool" onchange="get_template_vols({{ compute_id }}, value);">
{% if storages %}
<option value disabled selected>{% trans "Select pool" %}...</option>
{% for storage in storages %}
@ -622,36 +688,45 @@
</select>
</div>
<div class="col-sm-4">
<select class="form-select" id="template" name="template" disabled>
<select class="form-select{{ template_form.template|invalid }}" id="template" name="template" disabled
{% error_attrs template_form.template "template" %}>
<!-- populated from javascript -->
</select>
{% field_error template_form.template "template" %}
</div>
</div>
<div class="row">
<label for="storage" class="col-sm-3 col-form-label">{% trans "Storage" %}</label>
<div class="col-sm-7">
<select class="form-select" id="storage" name="storage" disabled>
<select class="form-select{{ template_form.storage|invalid }}" id="storage" name="storage" disabled
{% error_attrs template_form.storage "storage" %}>
{% if storages %}
{% for storage in storages %}
<option value="{{ storage }}" >{{ storage }}</option>
<option value="{{ storage }}" {% if storage == template_form.storage.value %}selected{% endif %}>{{ storage }}</option>
{% endfor %}
{% else %}
<option value="">{% trans "None" %}</option>
{% endif %}
</select>
{% field_error template_form.storage "storage" %}
</div>
</div>
<div class="row">
<label for="template_networks" class="col-sm-3 col-form-label">{% trans "Network/MAC" %}</label>
<div class="col-sm-3">
<select id="template_networks" class="form-select" name="networks">
<select id="template_networks" class="form-select{{ template_form.networks|invalid }}" name="networks"
{% error_attrs template_form.networks "template_networks" %}>
{% for network in networks %}
<option value="{{ network }}">{{ network }}</option>
<option value="{{ network }}" {% if network == template_form.networks.value %}selected{% endif %}>{{ network }}</option>
{% endfor %}
</select>
{% field_error template_form.networks "template_networks" %}
</div>
<div class="col-sm-4">
<input type="text" class="form-control" name="mac" maxlength="17" value="{{ mac_auto }}" required pattern="[a-zA-Z0-9:]+">
<input id="template_mac" aria-label="{% trans "MAC" %}" type="text" class="form-control{{ template_form.mac|invalid }}" name="mac"
value="{{ template_form.mac.value|default_if_none:"" }}" maxlength="17" required pattern="[a-zA-Z0-9:]+"
{% error_attrs template_form.mac "template_mac" %}>
{% field_error template_form.mac "template_mac" %}
</div>
</div>
<div class="row">
@ -666,103 +741,117 @@
<div class="row meta-prealloc">
<label for="template_meta_prealloc" class="col-sm-3 col-form-label">{% trans "Disk Metadata" %}</label>
<div class="col-sm-7">
<input id="template_meta_prealloc" type="checkbox" name="meta_prealloc" title="{% trans "Metadata preallocation" %}" value="true">
<input id="template_meta_prealloc" type="checkbox" name="meta_prealloc" title="{% trans "Metadata preallocation" %}" value="true"{% if template_form.meta_prealloc.value %} checked{% endif %}>
</div>
<label class="col-lg-1 col-form-label">{% trans "Image" %}</label>
</div>
<div class="row">
<label for="template_cache_mode" class="col-sm-3 col-form-label">{% trans "HDD cache mode" %}</label>
<div class="col-sm-7">
<select id="template_cache_mode" class="form-select" name="cache_mode">
<select id="template_cache_mode" class="form-select{{ template_form.cache_mode|invalid }}" name="cache_mode"
{% error_attrs template_form.cache_mode "template_cache_mode" %}>
{% for mode, name in cache_modes %}
<option value="{{ mode }}" {% if mode == default_cache %}selected{% endif %}>
<option value="{{ mode }}" {% if mode == template_form.cache_mode.value %}selected{% endif %}>
{% trans name %}</option>
{% endfor %}
</select>
{% field_error template_form.cache_mode "template_cache_mode" %}
</div>
</div>
<div class="row">
<label for="template_nwfilter" class="col-sm-3 col-form-label">{% trans "NWFilter" %}</label>
<div class="col-sm-7">
<select id="template_nwfilter" class="form-select" name="nwfilter">
<select id="template_nwfilter" class="form-select{{ template_form.nwfilter|invalid }}" name="nwfilter"
{% error_attrs template_form.nwfilter "template_nwfilter" %}>
<option value="">{% trans "None" %}</option>
{% for nwfilter in nwfilters %}
<option value="{{ nwfilter }}">{{ nwfilter }}</option>
<option value="{{ nwfilter }}" {% if nwfilter == template_form.nwfilter.value %}selected{% endif %}>{{ nwfilter }}</option>
{% endfor %}
</select>
{% field_error template_form.nwfilter "template_nwfilter" %}
</div>
</div>
<div class="row">
<label for="template_net_model" class="col-sm-3 col-form-label">{% trans "NIC Type" %} </label>
<div class="col-sm-7">
<select id="template_net_model" class="form-select" name="net_model">
<select id="template_net_model" class="form-select{{ template_form.net_model|invalid }}" name="net_model"
{% error_attrs template_form.net_model "template_net_model" %}>
{% for model in net_models_host %}
<option value="{{ model }}" {% if model == default_nic_type %} selected {% endif %}>{{ model }}</option>
<option value="{{ model }}" {% if model == template_form.net_model.value %} selected {% endif %}>{{ model }}</option>
{% endfor %}
</select>
{% field_error template_form.net_model "template_net_model" %}
</div>
</div>
<div class="row">
<label for="template_video" class="col-sm-3 col-form-label">{% trans "Video" %}</label>
<div class="col-sm-7">
<select id="template_video" class="form-select" name="video">
<select id="template_video" class="form-select{{ template_form.video|invalid }}" name="video"
{% error_attrs template_form.video "template_video" %}>
{% if not videos %}
<option value="vga">vga</option>
<option value="cirrus">cirrus</option>
{% endif %}
{% for video in videos %}
<option value="{{ video }}">{{ video }}</option>
<option value="{{ video }}" {% if video == template_form.video.value %}selected{% endif %}>{{ video }}</option>
{% endfor %}
</select>
{% field_error template_form.video "template_video" %}
</div>
</div>
<div class="row">
<label for="template_listener_addr" class="col-sm-3 col-form-label">{% trans "Console Access" %}</label>
<div class="col-sm-7">
<select id="template_listener_addr" class="form-select" name="listener_addr">
<select id="template_listener_addr" class="form-select{{ template_form.listener_addr|invalid }}" name="listener_addr"
{% error_attrs template_form.listener_addr "template_listener_addr" %}>
{% for addr, label in listener_addr %}
<option value="{{ addr }}" {% if addr == "0.0.0.0" %} selected {% endif %}>{{ label }}</option>
<option value="{{ addr }}" {% if addr == template_form.listener_addr.value %} selected {% endif %}>{{ label }}</option>
{% endfor %}
</select>
{% field_error template_form.listener_addr "template_listener_addr" %}
</div>
</div>
<div class="row">
<label for="template_add_cdrom" class="col-sm-3 col-form-label">{% trans "Add CD-Rom" %}</label>
<div class="col-sm-7">
<select id="template_add_cdrom" class="form-select" name="add_cdrom">
<option value="None" {% if bus == default_cdrom %}selected{% endif %}>--{% trans "False" %}--</option>
<select id="template_add_cdrom" class="form-select{{ template_form.add_cdrom|invalid }}" name="add_cdrom"
{% error_attrs template_form.add_cdrom "template_add_cdrom" %}>
<option value="None" {% if template_form.add_cdrom.value == "None" %}selected{% endif %}>--{% trans "False" %}--</option>
{% for bus in cdrom_buses %}
<option value="{{ bus }}" {% if bus == default_cdrom %}selected {% endif %}>
<option value="{{ bus }}" {% if bus == template_form.add_cdrom.value %}selected {% endif %}>
{% trans bus %}
</option>
{% endfor %}
</select>
{% field_error template_form.add_cdrom "template_add_cdrom" %}
</div>
</div>
<div class="row">
<label for="template_add_input" class="col-sm-3 col-form-label">{% trans "Add Input Device" %}</label>
<div class="col-sm-7">
<select id="template_add_input" class="form-select" name="add_input">
<option value="None" {% if bus == default_input_device_bus %}selected{% endif %}>--{% trans "False" %}--</option>
<select id="template_add_input" class="form-select{{ template_form.add_input|invalid }}" name="add_input"
{% error_attrs template_form.add_input "template_add_input" %}>
<option value="None" {% if template_form.add_input.value == "None" %}selected{% endif %}>--{% trans "False" %}--</option>
{% for bus in input_device_buses %}
<option value="{{ bus }}" {% if bus == default_input_device_bus %}selected{% endif %}>
<option value="{{ bus }}" {% if bus == template_form.add_input.value %}selected{% endif %}>
{% trans bus %}
</option>
{% endfor %}
</select>
{% field_error template_form.add_input "template_add_input" %}
</div>
</div>
<div class="row">
<label for="template_qemu_ga" class="col-sm-3 col-form-label">{% trans "Guest Agent" %}</label>
<div class="col-sm-6">
<input id="template_qemu_ga" type="checkbox" name="qemu_ga" value="true" checked>
<input id="template_qemu_ga" type="checkbox" name="qemu_ga" value="true"{% if template_form.qemu_ga.value %} checked{% endif %}>
</div>
</div>
{% if virtio_support %}
<div class="row">
<label for="template_virtio" class="col-sm-3 col-form-label">{% trans "VirtIO" %}</label>
<div class="col-sm-6">
<input id="template_virtio" type="checkbox" name="virtio" value="{{ virtio_support }}" checked>
<input id="template_virtio" type="checkbox" name="virtio" value="{{ virtio_support }}"{% if template_form.virtio.value %} checked{% endif %}>
</div>
</div>
{% endif %}
@ -912,7 +1001,7 @@
function get_template_vols(compute_id, pool) {
get_vol_url = "/computes/" + compute_id + "/storage/" + pool + "/volumes";
$.getJSON(get_vol_url, function (data) {
const loading = $.getJSON(get_vol_url, function (data) {
$("#template").find('option').remove();
$.each(data['vols'], function(i, item) {
$("#template").append($("<option>").val(item).text(item));
@ -921,8 +1010,50 @@
$("#template").removeAttr("disabled");
$("#storage").val(pool).change();
$("#storage").removeAttr("disabled");
return loading;
}
// A refused create form comes back open: its tab, or its flavor's dialog,
// marked data-form-id. Then the focus goes to its error, once the dialog
// is shown or the template form has its volumes back.
function reopen_submitted_form() {
const holder = document.querySelector(`[data-form-id="${document.body.dataset.submittedForm}"]`);
if (!holder) {
focusFirstError();
return;
}
open_sections_with_errors(holder);
if (holder.classList.contains("modal")) {
holder.addEventListener("shown.bs.modal", focusFirstError, {once: true});
bootstrap.Modal.getOrCreateInstance(holder).show();
} else {
bootstrap.Tab.getOrCreateInstance(document.querySelector(`[data-bs-target="#${holder.id}"]`)).show();
restore_template_volumes().then(focusFirstError);
}
}
// an "Advanced" section that holds a refused field
function open_sections_with_errors(holder) {
holder.querySelectorAll(".collapse").forEach(function (section) {
if (section.querySelector(".is-invalid")) {
bootstrap.Collapse.getOrCreateInstance(section, {toggle: false}).show();
}
});
}
// the template form's pool, template and storage, the template list
// loaded for the pool first, as when the user picks it
function restore_template_volumes() {
const form = document.querySelector("form[data-template-pool]");
if (!form || !form.dataset.templatePool) return $.when();
$("#template_disk").val(form.dataset.templatePool);
return get_template_vols({{ compute_id }}, form.dataset.templatePool).then(function () {
$("#template").val(form.dataset.template);
$("#storage").val(form.dataset.storage);
});
}
$(reopen_submitted_form);
function get_disk_bus_choices(compute_id, dev_idx, disk_type){
// the page's own architecture and machine type, from its URL
let arch = "{{ arch|escapejs }}";

View file

@ -0,0 +1,9 @@
{# Every error of a refused create form, at the top of the form: also those of fields it keeps behind a picker or hidden. #}
{% if form.errors %}
<div class="alert alert-danger form-errors" role="alert">
<ul class="mb-0">
{% for field in form %}{% for error in field.errors %}<li>{{ field.label }}: {{ error }}</li>{% endfor %}{% endfor %}
{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
</ul>
</div>
{% endif %}

View file

@ -0,0 +1,35 @@
"""Marking a field the server refused: its class, the ARIA attributes that
tie it to its message, and the message beside it. A field is a bound form
field; the page renders without forms when the host could not be read, so
anything without errors marks nothing."""
from django import template
from django.utils.html import format_html
register = template.Library()
def _errors(field):
return getattr(field, "errors", None)
@register.filter
def invalid(field):
"""" is-invalid", to append to the class of a refused field."""
return " is-invalid" if _errors(field) else ""
@register.simple_tag
def error_attrs(field, input_id):
"""aria-invalid and aria-describedby for a refused field."""
if not _errors(field):
return ""
return format_html(' aria-invalid="true" aria-describedby="{}_error"', input_id)
@register.simple_tag
def field_error(field, input_id):
"""The field's errors, shown beside it."""
if not _errors(field):
return ""
return format_html('<div class="invalid-feedback" id="{}_error">{}</div>', input_id, " ".join(_errors(field)))

View file

@ -0,0 +1,29 @@
"""The VM create page rendered on libvirt's test driver, for the tests of
that page."""
from unittest.mock import patch
import libvirt
from computes.models import Compute
from django.contrib.auth import get_user_model
from django.urls import reverse
def render_create_page(testcase, data=None):
"""GET the create page, or POST data (a create submission) to it, as a
superuser on a test-driver compute; returns (compute, response)."""
conn = libvirt.open("test:///default")
testcase.addCleanup(conn.close)
compute, _ = Compute.objects.get_or_create(name="testdrv", hostname="localhost", login="", password="", type=4)
user = get_user_model().objects.filter(username="page").first() or \
get_user_model().objects.create_superuser("page", "page@example.com", "pw")
testcase.client.force_login(user)
url = reverse("instances:create_instance", args=[compute.id, "i686", "pc"])
with patch("vrtManager.connection.connection_manager.get_connection", return_value=conn), \
patch("vrtManager.connection.wvmConnect.get_machine_types", return_value=["pc"]), \
patch("vrtManager.connection.wvmConnect.get_nwfilters", return_value=[]):
if data is None:
response = testcase.client.get(url)
else:
response = testcase.client.post(url, {"create": "", **data})
return compute, response

View file

@ -0,0 +1,181 @@
"""A refused VM create form comes back bound and open, with what was
entered; the other create forms keep their own defaults."""
import uuid
from unittest.mock import patch
from django.test import TestCase
from django.urls import reverse
from instances.models import Flavor
from instances.tests.create_page import render_create_page
from lxml import html
from operations import claims, jobs
from operations.models import Operation
from vrtManager import util
def valid(**fields):
"""A create submission NewVMForm accepts."""
return {"name": "vm1", "firmware": "BIOS", "vcpu": "1", "memory": "512", "networks": "default",
"cache_mode": "default", "add_cdrom": "None", "add_input": "None", "video": "vga",
"listener_addr": "0.0.0.0", "submission_key": uuid.uuid4().hex, **fields}
class CreateFormInputTests(TestCase):
def setUp(self):
self.flavor = Flavor.objects.create(label="wvc-test", memory=512, vcpu=1, disk=10)
def test_each_create_form_has_its_own_unbound_form(self):
_, response = render_create_page(self)
forms = response.context["vm_forms"]
self.assertEqual(set(forms), {"custom", "template"} | {f"flavor{f.id}" for f in Flavor.objects.all()})
self.assertFalse(any(form.is_bound for form in forms.values()))
self.assertEqual(response.context["submitted_form"], "")
def test_the_refused_form_comes_back_bound(self):
_, response = render_create_page(self, valid(form_id="custom", name="bad name!", vcpu="2"))
self.assertEqual(response.context["submitted_form"], "custom")
self.assertTrue(response.context["custom_form"].is_bound)
self.assertFalse(response.context["template_form"].is_bound)
self.assertIn("name", response.context["custom_form"].errors)
def test_a_refusal_after_validation_keeps_the_form(self):
for refusal in (claims.Busy("compute 1"), jobs.SubmissionConflict("already submitted"),
util.OperationError("A virtual machine with this name already exists")):
with self.subTest(refusal=type(refusal).__name__), \
patch("instances.views.jobs.submit", side_effect=refusal):
_, response = render_create_page(self, valid(form_id="template"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["submitted_form"], "template")
self.assertTrue(response.context["template_form"].is_bound)
self.assertTrue(list(response.context["messages"]))
# the view's own refusal: a new disk needs a MAC
_, response = render_create_page(self, valid(form_id="template", hdd_size="10", mac=""))
self.assertEqual(response.context["submitted_form"], "template")
self.assertTrue(response.context["template_form"].is_bound)
def test_a_stale_form_id_is_no_form(self):
_, response = render_create_page(self, valid(form_id="flavor999", name="bad name!"))
self.assertEqual(response.context["submitted_form"], "")
# no form shows the error: it goes to the messages
self.assertTrue(any("special characters" in str(m) for m in response.context["messages"]))
def test_a_valid_post_with_a_stale_form_id_still_queues_the_job(self):
compute, response = render_create_page(self, valid(
form_id="flavor999", hdd_size="10", storage="default-pool", mac="52:54:00:00:00:01"))
self.assertRedirects(response, reverse("instances", args=[compute.id]), fetch_redirect_response=False)
self.assertEqual(Operation.objects.get().kind, "instance.create")
class CreateFormValuesTests(TestCase):
"""The custom and template forms render their own form's values and errors."""
def setUp(self):
Flavor.objects.create(label="wvc-test", memory=512, vcpu=1, disk=10)
def page(self, **fields):
_, response = render_create_page(self, valid(**fields))
self.assertEqual(response.status_code, 200)
return html.fromstring(response.content)
def summary(self, page, form_label):
return " ".join(page.xpath(f"//form[@aria-label='{form_label}']//*[contains(@class, 'form-errors')]//text()"))
def test_the_custom_form_keeps_what_was_submitted(self):
page = self.page(form_id="custom", name="bad name!", vcpu="3", memory="2048", cache_mode="writeback")
self.assertEqual(page.get_element_by_id("custom_name").get("value"), "bad name!")
self.assertEqual(page.get_element_by_id("custom_vcpu").get("value"), "3")
self.assertEqual(page.get_element_by_id("custom_memory").get("value"), "2048")
self.assertEqual(page.xpath("//select[@id='cache_mode']/option[@selected]/@value"), ["writeback"])
self.assertIsNone(page.get_element_by_id("custom_qemu_ga").get("checked")) # not submitted: unchecked
# the template form keeps its defaults
self.assertEqual(page.get_element_by_id("template_vcpu").get("value"), "1")
self.assertIsNotNone(page.get_element_by_id("template_qemu_ga").get("checked"))
def test_a_blank_number_stays_blank(self):
page = self.page(form_id="custom", vcpu="")
self.assertEqual(page.get_element_by_id("custom_vcpu").get("value"), "")
self.assertTrue(page.get_element_by_id("custom_vcpu_error").text_content().strip())
def test_the_name_is_escaped(self):
page = self.page(form_id="custom", name='<b>x</b>"')
self.assertEqual(page.get_element_by_id("custom_name").get("value"), '<b>x</b>"')
self.assertEqual(page.xpath("//form[@aria-label='Create custom instance form']//b"), [])
def test_the_error_sits_beside_its_field(self):
page = self.page(form_id="custom", name="bad name!")
field = page.get_element_by_id("custom_name")
self.assertIn("is-invalid", field.get("class").split())
self.assertEqual((field.get("aria-invalid"), field.get("aria-describedby")), ("true", "custom_name_error"))
self.assertIn("special characters", page.get_element_by_id("custom_name_error").text_content())
# and in the summary at the top of its form
self.assertIn("Name: The name of the virtual machine", self.summary(page, "Create custom instance form"))
def test_the_template_form_keeps_its_pool_template_and_storage(self):
page = self.page(form_id="template", name="bad name!", template_pool="default-pool",
template="base.img", storage="default-pool")
form = page.xpath("//form[@aria-label='Create instance from template form']")[0]
self.assertEqual((form.get("data-template-pool"), form.get("data-template"), form.get("data-storage")),
("default-pool", "base.img", "default-pool"))
self.assertEqual(page.get_element_by_id("template_disk").get("name"), "template_pool")
def test_an_error_of_a_field_the_form_hides_is_in_its_summary(self):
# the custom form's networks are a hidden input behind a picker
page = self.page(form_id="custom", networks="")
self.assertIn("Networks: This field is required.", self.summary(page, "Create custom instance form"))
def test_each_form_sends_its_own_form_id(self):
page = self.page(form_id="custom", name="bad name!")
for label, form_id in (("Create custom instance form", "custom"), ("Create instance from template form", "template")):
with self.subTest(form=form_id):
form = page.xpath(f"//form[@aria-label='{label}']")[0]
self.assertEqual(form.xpath(".//input[@name='form_id']/@value"), [form_id])
def test_a_firmware_error_without_a_firmware_choice_is_in_the_summary(self):
# an architecture with no firmware label renders no firmware select
with patch("vrtManager.connection.wvmConnect.label_for_firmware_path", return_value=None):
fields = valid(form_id="custom")
del fields["firmware"]
_, response = render_create_page(self, fields)
page = html.fromstring(response.content)
self.assertEqual(page.xpath("//select[@name='firmware']"), [])
self.assertIn("Firmware: This field is required.", self.summary(page, "Create custom instance form"))
class FlavorFormTests(TestCase):
"""Each flavor's modal form renders its own form; the refused one comes back."""
def setUp(self):
self.first = Flavor.objects.create(label="wvc-test-1", memory=512, vcpu=1, disk=10)
self.second = Flavor.objects.create(label="wvc-test-2", memory=1024, vcpu=2, disk=20)
def page(self, **fields):
_, response = render_create_page(self, valid(**fields))
self.assertEqual(response.status_code, 200)
return html.fromstring(response.content)
def test_only_the_refused_flavor_comes_back(self):
page = self.page(form_id=f"flavor{self.second.id}", name="bad name!", vcpu="2", memory="1024", hdd_size="20")
self.assertEqual(page.get_element_by_id(f"flavor{self.second.id}_name").get("value"), "bad name!")
self.assertEqual(page.get_element_by_id(f"flavor{self.first.id}_name").get("value"), "")
self.assertTrue(page.get_element_by_id(f"flavor{self.first.id}_mac").get("value"))
def test_a_flavor_field_points_at_its_error(self):
page = self.page(form_id=f"flavor{self.second.id}", name="bad name!", vcpu="2", memory="1024", hdd_size="20")
field = page.get_element_by_id(f"flavor{self.second.id}_name")
error = page.get_element_by_id(field.get("aria-describedby"))
self.assertIn("special characters", error.text_content())
def test_a_reopened_flavor_holds_the_message(self):
with patch("instances.views.jobs.submit",
side_effect=util.OperationError("A virtual machine with this name already exists")):
page = self.page(form_id=f"flavor{self.second.id}", vcpu="2", memory="1024", hdd_size="20",
storage="default-pool", mac="52:54:00:00:00:01")
messages = page.xpath(f"//*[@id='addVMflavor{self.second.id}']//*[contains(@class, 'modal-messages')]")
self.assertIn("A virtual machine with this name already exists", messages[0].text_content())
def test_the_flavor_form_sends_one_cache_mode(self):
page = self.page(form_id="custom", name="bad name!")
form = page.get_element_by_id(f"addVMflavor{self.first.id}").xpath(".//form")[0]
self.assertEqual(len(form.xpath(".//*[@name='cache_mode']")), 1)
self.assertEqual(form.xpath(".//input[@name='form_id']/@value"), [f"flavor{self.first.id}"])

View file

@ -6,27 +6,17 @@ import re
import shutil
import subprocess
import unittest
from unittest.mock import patch
import libvirt
from computes.models import Compute
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from instances.forms import NewVMForm
from instances.tests.create_page import render_create_page
from lxml import html
class CreatePageScriptTests(TestCase):
def page(self):
conn = libvirt.open("test:///default")
self.addCleanup(conn.close)
compute = Compute.objects.create(name="testdrv", hostname="localhost", login="", password="", type=4)
self.client.force_login(get_user_model().objects.create_superuser("js", "js@example.com", "pw"))
with patch("vrtManager.connection.connection_manager.get_connection", return_value=conn), \
patch("vrtManager.connection.wvmConnect.get_machine_types", return_value=["pc"]), \
patch("vrtManager.connection.wvmConnect.get_nwfilters", return_value=[]):
response = self.client.get(reverse("instances:create_instance", args=[compute.id, "i686", "pc"]))
compute, response = render_create_page(self)
self.assertEqual(response.status_code, 200)
return compute, response.content.decode()
@ -58,3 +48,42 @@ class CreatePageScriptTests(TestCase):
def test_a_mac_per_nic_fits_the_form(self):
macs = ",".join(f"52:54:00:00:00:{n:02x}" for n in range(8))
self.assertEqual(NewVMForm.base_fields["mac"].clean(macs), macs)
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_the_page_reopens_the_submitted_form(self):
_, page = self.page()
functions = re.search(r"function reopen_submitted_form\(.*?\n \$\(reopen_submitted_form\);", page, re.S).group(0)
harness = """
const shown = [];
// the forms' holders: two tab panes and a flavor's dialog, each with an
// "Advanced" section that holds a refused field; a dialog's "shown"
// listeners run when it is shown, as in Bootstrap
const holder = (id, kind) => ({id, listeners: [], classList: {contains: c => c === kind},
querySelectorAll: () => [{name: "#" + id + " .collapse", querySelector: () => ({})}],
addEventListener(event, f) { this.listeners.push(f); }});
const holders = {custom: holder("addCustom", "tab-pane"), template: holder("add-from-temp", "tab-pane"),
flavor7: holder("addVMflavor7", "modal")};
const document = {body: {dataset: {submittedForm: %s}}, querySelector: sel => {
const id = (sel.match(/data-form-id="([^"]*)"/) || [])[1];
if (id !== undefined) return holders[id] || null;
return sel.startsWith("form[") ? null : {name: sel}; // no refused template form here
}};
const bootstrap = {Tab: {getOrCreateInstance: e => ({show: () => shown.push("tab " + e.name)})},
Modal: {getOrCreateInstance: e => ({show: () => { shown.push("modal #" + e.id); e.listeners.forEach(f => f()); }})},
Collapse: {getOrCreateInstance: e => ({show: () => shown.push("collapse " + e.name)})}};
const $ = Object.assign(() => ({val() {}}), {when: () => ({then: f => f()})});
const focusFirstError = () => shown.push("focus");
%s
reopen_submitted_form(); console.log(JSON.stringify(shown));
"""
for submitted, expected in (
("custom", ["collapse #addCustom .collapse", 'tab [data-bs-target="#addCustom"]', "focus"]),
("template", ["collapse #add-from-temp .collapse", 'tab [data-bs-target="#add-from-temp"]', "focus"]),
("flavor7", ["collapse #addVMflavor7 .collapse", "modal #addVMflavor7", "focus"]),
("", ["focus"]),
):
with self.subTest(submitted=submitted):
result = subprocess.run(["node", "-e", harness % (json.dumps(submitted), functions.replace("$(reopen_submitted_form);", ""))],
capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), expected)

View file

@ -2009,6 +2009,21 @@ def create_instance_select_type(request, compute_id):
return render(request, "create_instance_w1.html", locals())
def create_forms(flavors, defaults, mac):
"""One unbound NewVMForm per create form of the page, keyed by its
form_id, each with that form's defaults."""
forms = {
"custom": NewVMForm(initial={**defaults, "vcpu": 1, "memory": 512}),
"template": NewVMForm(initial={**defaults, "vcpu": 1, "memory": 512, "mac": mac}),
}
for flavor in flavors:
forms[f"flavor{flavor.id}"] = NewVMForm(initial={
**defaults, "vcpu": flavor.vcpu, "memory": flavor.memory, "hdd_size": flavor.disk,
"mac": mac,
})
return forms
@superuser_only
def create_instance(request, compute_id, arch, machine):
"""
@ -2028,6 +2043,8 @@ def create_instance(request, compute_id, arch, machine):
compute = get_object_or_404(Compute, pk=compute_id)
flavors = Flavor.objects.filter().order_by("id")
appsettings = AppSettings.objects.all()
vm_forms = {}
submitted_form = ""
try:
conn = wvmCreate(
@ -2083,6 +2100,13 @@ def create_instance(request, compute_id, arch, machine):
flavor_form = FlavorForm()
vm_forms = create_forms(flavors, mac=mac_auto, defaults={
"firmware": default_firmware, "vcpu_mode": default_cpu_mode, "cache_mode": default_cache,
"net_model": default_nic_type, "listener_addr": "0.0.0.0", "add_cdrom": default_cdrom,
"add_input": default_input_device_bus, "video": default_video, "qemu_ga": True,
"virtio": bool(virtio_support),
})
if conn:
if not storages:
raise util.OperationError(_("You haven't defined any storage pools"))
@ -2092,6 +2116,16 @@ def create_instance(request, compute_id, arch, machine):
if request.method == "POST":
if "create" in request.POST:
form = NewVMForm(request.POST)
# the submitted form comes back bound if the page shows it again;
# a stale form_id (a flavor deleted since) has no form for its errors
form_id = request.POST.get("form_id", "")
if form_id in vm_forms:
vm_forms[form_id] = form
submitted_form = form_id
else:
for name, errors in form.errors.items():
for error in errors:
messages.error(request, f"{form[name].label}: {error}" if name in form.fields else error)
if form.is_valid():
data = form.cleaned_data
if data["hdd_size"] and not data["mac"]:
@ -2121,6 +2155,9 @@ def create_instance(request, compute_id, arch, machine):
except libvirtError as lib_err:
messages.error(request, lib_err)
custom_form, template_form = vm_forms.get("custom"), vm_forms.get("template")
flavor_forms = [(flavor, f"flavor{flavor.id}", vm_forms[f"flavor{flavor.id}"])
for flavor in flavors if f"flavor{flavor.id}" in vm_forms]
return render(request, "create_instance_w2.html", locals())

View file

@ -34,7 +34,7 @@
<link rel="shortcut icon" href="{% static 'favicon.ico' %}">
</head>
<body>
<body{% block body_attrs %}{% endblock %}>
{% if request.user.is_authenticated %}
{% include 'navbar.html' %}
@ -67,16 +67,26 @@
<!-- JavaScript Cookie -->
<script src="{% static 'js/js.cookie.js' %}"></script>
<script>
// after a refused submission, start at the error: the first visible
// invalid field, else the error message
(function () {
const field = [...document.querySelectorAll(".is-invalid")].find(e => e.offsetParent !== null);
const target = field || document.querySelector("#messages .alert-danger");
if (target) {
if (!field) target.tabIndex = -1;
target.focus();
// After a refused submission, start at the error. The places an error
// can be, most specific first: a refused field, its form's summary, an
// open dialog's copy of the messages (the page's are behind it), the
// page's messages. A page that sets itself up first has
// data-error-focus="manual" and calls this when it is ready.
const ERROR_PLACES = [".is-invalid", ".form-errors", ".modal.show .modal-messages .alert-danger",
"#messages .alert-danger"];
function focusFirstError() {
for (const selector of ERROR_PLACES) {
const target = [...document.querySelectorAll(selector)].find(e => e.offsetParent !== null);
if (target) {
if (!target.matches("input, select, textarea")) target.tabIndex = -1;
target.focus();
return;
}
}
})();
}
window.focusFirstError = focusFirstError;
if (document.body.dataset.errorFocus !== "manual") focusFirstError();
</script>
{% block script %}{% endblock %}
</body>

View file

@ -12,7 +12,7 @@ from pathlib import Path
from django.conf import settings
SCRIPT = re.search(
r"<script>\s*(// after a refused submission.*?)</script>",
r"<script>\s*(// After a refused submission.*?)</script>",
Path(settings.BASE_DIR, "templates/base.html").read_text(),
re.S,
).group(1)
@ -20,30 +20,45 @@ SCRIPT = re.search(
HARNESS = """
const els = %s;
let focused = null;
for (const e of els) e.focus = () => { focused = e; };
for (const e of els) { e.focus = () => { focused = e; }; e.matches = () => Boolean(e.field); }
const document = {
body: {dataset: %s},
querySelectorAll: sel => els.filter(e => e.sel === sel),
querySelector: sel => els.find(e => e.sel === sel) || null,
};
const window = globalThis;
%s
if (%s) window.focusFirstError();
console.log(JSON.stringify({focused: focused && focused.name, tabIndex: focused && focused.tabIndex !== undefined ? focused.tabIndex : null}));
"""
class ErrorFocusTests(unittest.TestCase):
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def focused(self, els, dataset=None, call=False):
script = HARNESS % (json.dumps(els), json.dumps(dataset or {}), SCRIPT, "true" if call else "false")
result = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
return json.loads(result.stdout)
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_focus_goes_to_the_error(self):
invalid = {"sel": ".is-invalid", "offsetParent": {}, "name": "field"}
hidden = {"sel": ".is-invalid", "offsetParent": None, "name": "hidden"}
invalid = {"sel": ".is-invalid", "offsetParent": {}, "name": "field", "field": True}
hidden = {"sel": ".is-invalid", "offsetParent": None, "name": "hidden", "field": True}
summary = {"sel": ".form-errors", "offsetParent": {}, "name": "summary"}
alert = {"sel": "#messages .alert-danger", "offsetParent": {}, "name": "message"}
in_modal = {"sel": ".modal.show .modal-messages .alert-danger", "offsetParent": {}, "name": "modal message"}
for els, expected in (
([hidden, invalid, alert], {"focused": "field", "tabIndex": None}),
([hidden, invalid, summary, alert], {"focused": "field", "tabIndex": None}),
([hidden, summary, in_modal, alert], {"focused": "summary", "tabIndex": -1}), # a hidden field's error
([hidden, alert], {"focused": "message", "tabIndex": -1}),
([hidden, in_modal, alert], {"focused": "modal message", "tabIndex": -1}), # the page's copy is behind the dialog
([], {"focused": None, "tabIndex": None}),
):
with self.subTest(expected=expected["focused"]):
result = subprocess.run(
["node", "-e", HARNESS % (json.dumps(els), SCRIPT)], capture_output=True, text=True, timeout=30
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), expected)
self.assertEqual(self.focused(els), expected)
@unittest.skipUnless(shutil.which("node"), "node is not installed")
def test_a_page_can_focus_the_error_itself(self):
invalid = {"sel": ".is-invalid", "offsetParent": {}, "name": "field", "field": True}
self.assertEqual(self.focused([invalid], {"errorFocus": "manual"}), {"focused": None, "tabIndex": None})
self.assertEqual(self.focused([invalid], {"errorFocus": "manual"}, call=True), {"focused": "field", "tabIndex": None})