mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
* 8356 add virtual disk model * 8356 add supplemental forms * 8356 add menu * 8356 cleanup views * 8356 virtual machine tab * 8356 migrations * 8356 vm disk tables * 8356 cleanup * 8356 graphql * 8356 graphql * 8356 add components button * 8356 bulk add on virtualmachine * 8356 bulk add fixes * 8356 api tests * 8356 news tests add rename * 8356 VirtualDiskCreateForm * 8356 fix test * 8356 add todo to remove disk from vm * 8356 review changes * 8356 fix test * 8356 deprecate disk field * 8356 review changes * 8356 fix test * 8356 fix test * Simplify view actions * 8356 review changes * 8356 split trans tag * 8356 add total virtual disk size to api * 8356 add virtual disk list to virtual machine detail view * 8356 move virtual disk size to property * 8356 revert property * Tweak display of deprecated disk field * 8356 render single disk field * 8356 update serializer * 8356 model property * 8356 fix test * 8356 review changes * Revert disk space annotation * Use existing disk field to store aggregate virtual disk size * Introduce abstract ComponentModel for VM components * Add search index for VirtualDisk * Misc cleanup --------- Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
parent
e13bf48a35
commit
549b0ea107
@ -218,6 +218,7 @@ VIRTUALIZATION_MENU = Menu(
|
||||
items=(
|
||||
get_model_item('virtualization', 'virtualmachine', _('Virtual Machines')),
|
||||
get_model_item('virtualization', 'vminterface', _('Interfaces')),
|
||||
get_model_item('virtualization', 'virtualdisk', _('Virtual Disks')),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
|
59
netbox/templates/virtualization/virtualdisk.html
Normal file
59
netbox/templates/virtualization/virtualdisk.html
Normal file
@ -0,0 +1,59 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{% url 'virtualization:virtualmachine_disks' pk=object.virtual_machine.pk %}">{{ object.virtual_machine }}</a>
|
||||
</li>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Virtual Disk" %}</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Virtual Machine" %}</th>
|
||||
<td>{{ object.virtual_machine|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Name" %}</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><i class="mdi mdi-harddisk"></i> {% trans "Size" %}</th>
|
||||
<td>
|
||||
{% if object.size %}
|
||||
{{ object.size }} {% trans "GB" context "Abbreviation for gigabyte" %}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -139,14 +139,16 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row"><i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}</th>
|
||||
<td>
|
||||
{% if object.disk %}
|
||||
{{ object.disk }} {% trans "GB" context "Abbreviation for gigabyte" %}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<th scope="row">
|
||||
<i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}
|
||||
</th>
|
||||
<td>
|
||||
{% if object.disk %}
|
||||
{{ object.disk }} {% trans "GB" context "Abbreviation for gigabyte" %}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@ -168,6 +170,26 @@
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h5 class="card-header">{% trans "Virtual Disks" %}</h5>
|
||||
<div class="card-body htmx-container table-responsive"
|
||||
hx-get="{% url 'virtualization:virtualdisk_list' %}?virtual_machine_id={{ object.pk }}"
|
||||
hx-trigger="load"
|
||||
></div>
|
||||
{% if perms.virtualization.add_virtualdisk %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'virtualization:virtualdisk_add' %}?device={{ object.device.pk }}&virtual_machine={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-primary">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Virtual Disk" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% plugin_full_width_page object %}
|
||||
|
@ -16,9 +16,23 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_controls %}
|
||||
{% if perms.virtualization.add_vminterface %}
|
||||
<a href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-sm btn-primary">
|
||||
<i class="mdi mdi-plus-thick"></i> {% trans "Add Interfaces" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="dropdown">
|
||||
<button id="add-components" type="button" class="btn btn-sm btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labeled-by="add-components">
|
||||
{% if perms.virtualization.add_vminterface %}
|
||||
<li><a class="dropdown-item" href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">
|
||||
{% trans "Interfaces" %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
{% if perms.virtualization.add_virtualdisk %}
|
||||
<li><a class="dropdown-item" href="{% url 'virtualization:virtualdisk_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_disks' pk=object.pk %}">
|
||||
{% trans "Virtual Disks" %}
|
||||
</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
@ -0,0 +1,14 @@
|
||||
{% extends 'generic/object_children.html' %}
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block bulk_edit_controls %}
|
||||
{{ block.super }}
|
||||
{% if 'bulk_rename' in actions %}
|
||||
<button type="submit" name="_rename"
|
||||
formaction="{% url 'virtualization:virtualdisk_bulk_rename' %}?return_url={{ return_url }}"
|
||||
class="btn btn-outline-warning btn-sm">
|
||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock bulk_edit_controls %}
|
@ -15,6 +15,13 @@
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.virtualization.add_virtualdisk %}
|
||||
<li>
|
||||
<button type="submit" formaction="{% url 'virtualization:virtualmachine_bulk_add_virtualdisk' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
|
||||
{% trans "Virtual Disks" %}
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -2,12 +2,13 @@ from drf_spectacular.utils import extend_schema_serializer
|
||||
from rest_framework import serializers
|
||||
|
||||
from netbox.api.serializers import WritableNestedSerializer
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
from virtualization.models import *
|
||||
|
||||
__all__ = [
|
||||
'NestedClusterGroupSerializer',
|
||||
'NestedClusterSerializer',
|
||||
'NestedClusterTypeSerializer',
|
||||
'NestedVirtualDiskSerializer',
|
||||
'NestedVMInterfaceSerializer',
|
||||
'NestedVirtualMachineSerializer',
|
||||
]
|
||||
@ -72,3 +73,12 @@ class NestedVMInterfaceSerializer(WritableNestedSerializer):
|
||||
class Meta:
|
||||
model = VMInterface
|
||||
fields = ['id', 'url', 'display', 'virtual_machine', 'name']
|
||||
|
||||
|
||||
class NestedVirtualDiskSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualdisk-detail')
|
||||
virtual_machine = NestedVirtualMachineSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VirtualDisk
|
||||
fields = ['id', 'url', 'display', 'virtual_machine', 'name', 'size']
|
||||
|
@ -14,7 +14,7 @@ from netbox.api.fields import ChoiceField, SerializedPKRelatedField
|
||||
from netbox.api.serializers import NetBoxModelSerializer
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from virtualization.choices import *
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualDisk, VirtualMachine, VMInterface
|
||||
from .nested_serializers import *
|
||||
|
||||
|
||||
@ -84,6 +84,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
|
||||
|
||||
# Counter fields
|
||||
interface_count = serializers.IntegerField(read_only=True)
|
||||
virtual_disk_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VirtualMachine
|
||||
@ -91,7 +92,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
|
||||
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
|
||||
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
|
||||
'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'interface_count',
|
||||
'interface_count', 'virtual_disk_count',
|
||||
]
|
||||
validators = []
|
||||
|
||||
@ -104,7 +105,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
|
||||
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
|
||||
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
|
||||
'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
|
||||
'interface_count',
|
||||
'interface_count', 'virtual_disk_count',
|
||||
]
|
||||
|
||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||
@ -159,3 +160,19 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
|
||||
})
|
||||
|
||||
return super().validate(data)
|
||||
|
||||
|
||||
#
|
||||
# Virtual Disk
|
||||
#
|
||||
|
||||
class VirtualDiskSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualdisk-detail')
|
||||
virtual_machine = NestedVirtualMachineSerializer()
|
||||
|
||||
class Meta:
|
||||
model = VirtualDisk
|
||||
fields = [
|
||||
'id', 'url', 'virtual_machine', 'name', 'description', 'size', 'tags', 'custom_fields', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
@ -13,6 +13,7 @@ router.register('clusters', views.ClusterViewSet)
|
||||
# VirtualMachines
|
||||
router.register('virtual-machines', views.VirtualMachineViewSet)
|
||||
router.register('interfaces', views.VMInterfaceViewSet)
|
||||
router.register('virtual-disks', views.VirtualDiskViewSet)
|
||||
|
||||
app_name = 'virtualization-api'
|
||||
urlpatterns = router.urls
|
||||
|
@ -6,7 +6,7 @@ from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from utilities.query_functions import CollateAsChar
|
||||
from utilities.utils import count_related
|
||||
from virtualization import filtersets
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
from virtualization.models import *
|
||||
from . import serializers
|
||||
|
||||
|
||||
@ -55,7 +55,8 @@ class ClusterViewSet(NetBoxModelViewSet):
|
||||
|
||||
class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet):
|
||||
queryset = VirtualMachine.objects.prefetch_related(
|
||||
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'config_template', 'tags'
|
||||
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'config_template',
|
||||
'tags', 'virtualdisks',
|
||||
)
|
||||
filterset_class = filtersets.VirtualMachineFilterSet
|
||||
|
||||
@ -92,3 +93,12 @@ class VMInterfaceViewSet(NetBoxModelViewSet):
|
||||
def get_bulk_destroy_queryset(self):
|
||||
# Ensure child interfaces are deleted prior to their parents
|
||||
return self.get_queryset().order_by('virtual_machine', 'parent', CollateAsChar('_name'))
|
||||
|
||||
|
||||
class VirtualDiskViewSet(NetBoxModelViewSet):
|
||||
queryset = VirtualDisk.objects.prefetch_related(
|
||||
'virtual_machine', 'tags',
|
||||
)
|
||||
serializer_class = serializers.VirtualDiskSerializer
|
||||
filterset_class = filtersets.VirtualDiskFilterSet
|
||||
brief_prefetch_fields = ['virtual_machine']
|
||||
|
@ -5,7 +5,7 @@ class VirtualizationConfig(AppConfig):
|
||||
name = 'virtualization'
|
||||
|
||||
def ready(self):
|
||||
from . import search
|
||||
from . import search, signals
|
||||
from .models import VirtualMachine
|
||||
from utilities.counters import connect_counters
|
||||
|
||||
|
@ -11,12 +11,13 @@ from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
|
||||
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
|
||||
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
|
||||
from .choices import *
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
from .models import *
|
||||
|
||||
__all__ = (
|
||||
'ClusterFilterSet',
|
||||
'ClusterGroupFilterSet',
|
||||
'ClusterTypeFilterSet',
|
||||
'VirtualDiskFilterSet',
|
||||
'VirtualMachineFilterSet',
|
||||
'VMInterfaceFilterSet',
|
||||
)
|
||||
@ -305,3 +306,29 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class VirtualDiskFilterSet(NetBoxModelFilterSet):
|
||||
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='virtual_machine',
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
label=_('Virtual machine (ID)'),
|
||||
)
|
||||
virtual_machine = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='virtual_machine__name',
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
to_field_name='name',
|
||||
label=_('Virtual machine'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VirtualDisk
|
||||
fields = ['id', 'name', 'size', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
@ -3,9 +3,10 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from utilities.forms import BootstrapMixin, form_from_model
|
||||
from utilities.forms.fields import ExpandableNameField
|
||||
from virtualization.models import VMInterface, VirtualMachine
|
||||
from virtualization.models import VirtualDisk, VMInterface, VirtualMachine
|
||||
|
||||
__all__ = (
|
||||
'VirtualDiskBulkCreateForm',
|
||||
'VMInterfaceBulkCreateForm',
|
||||
)
|
||||
|
||||
@ -30,3 +31,10 @@ class VMInterfaceBulkCreateForm(
|
||||
VirtualMachineBulkAddComponentForm
|
||||
):
|
||||
replication_fields = ('name',)
|
||||
|
||||
|
||||
class VirtualDiskBulkCreateForm(
|
||||
form_from_model(VirtualDisk, ['size', 'description', 'tags']),
|
||||
VirtualMachineBulkAddComponentForm
|
||||
):
|
||||
replication_fields = ('name',)
|
||||
|
@ -18,6 +18,8 @@ __all__ = (
|
||||
'ClusterBulkEditForm',
|
||||
'ClusterGroupBulkEditForm',
|
||||
'ClusterTypeBulkEditForm',
|
||||
'VirtualDiskBulkEditForm',
|
||||
'VirtualDiskBulkRenameForm',
|
||||
'VirtualMachineBulkEditForm',
|
||||
'VMInterfaceBulkEditForm',
|
||||
'VMInterfaceBulkRenameForm',
|
||||
@ -315,3 +317,35 @@ class VMInterfaceBulkRenameForm(BulkRenameForm):
|
||||
queryset=VMInterface.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
|
||||
|
||||
class VirtualDiskBulkEditForm(NetBoxModelBulkEditForm):
|
||||
virtual_machine = forms.ModelChoiceField(
|
||||
label=_('Virtual machine'),
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
disabled=True,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
size = forms.IntegerField(
|
||||
required=False,
|
||||
label=_('Size (GB)')
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
max_length=100,
|
||||
required=False
|
||||
)
|
||||
|
||||
model = VirtualDisk
|
||||
fieldsets = (
|
||||
(None, ('size', 'description')),
|
||||
)
|
||||
nullable_fields = ('description',)
|
||||
|
||||
|
||||
class VirtualDiskBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=VirtualDisk.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
|
@ -14,6 +14,7 @@ __all__ = (
|
||||
'ClusterImportForm',
|
||||
'ClusterGroupImportForm',
|
||||
'ClusterTypeImportForm',
|
||||
'VirtualDiskImportForm',
|
||||
'VirtualMachineImportForm',
|
||||
'VMInterfaceImportForm',
|
||||
)
|
||||
@ -199,3 +200,17 @@ class VMInterfaceImportForm(NetBoxModelImportForm):
|
||||
return True
|
||||
else:
|
||||
return self.cleaned_data['enabled']
|
||||
|
||||
|
||||
class VirtualDiskImportForm(NetBoxModelImportForm):
|
||||
virtual_machine = CSVModelChoiceField(
|
||||
label=_('Virtual machine'),
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
to_field_name='name'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VirtualDisk
|
||||
fields = (
|
||||
'virtual_machine', 'name', 'size', 'description', 'tags'
|
||||
)
|
||||
|
@ -16,6 +16,7 @@ __all__ = (
|
||||
'ClusterFilterForm',
|
||||
'ClusterGroupFilterForm',
|
||||
'ClusterTypeFilterForm',
|
||||
'VirtualDiskFilterForm',
|
||||
'VirtualMachineFilterForm',
|
||||
'VMInterfaceFilterForm',
|
||||
)
|
||||
@ -221,3 +222,23 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
|
||||
label=_('L2VPN')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class VirtualDiskFilterForm(NetBoxModelFilterSetForm):
|
||||
model = VirtualDisk
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Virtual Machine'), ('virtual_machine_id',)),
|
||||
(_('Attributes'), ('size',)),
|
||||
)
|
||||
virtual_machine_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
label=_('Virtual machine')
|
||||
)
|
||||
size = forms.IntegerField(
|
||||
label=_('Size (GB)'),
|
||||
required=False,
|
||||
min_value=1
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
@ -22,6 +22,7 @@ __all__ = (
|
||||
'ClusterGroupForm',
|
||||
'ClusterRemoveDevicesForm',
|
||||
'ClusterTypeForm',
|
||||
'VirtualDiskForm',
|
||||
'VirtualMachineForm',
|
||||
'VMInterfaceForm',
|
||||
)
|
||||
@ -240,6 +241,11 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
|
||||
|
||||
if self.instance.pk:
|
||||
|
||||
# Disable the disk field if one or more VirtualDisks have been created
|
||||
if self.instance.virtualdisks.exists():
|
||||
self.fields['disk'].widget.attrs['disabled'] = True
|
||||
self.fields['disk'].help_text = _("Disk size is managed via the attachment of virtual disks.")
|
||||
|
||||
# Compile list of choices for primary IPv4 and IPv6 addresses
|
||||
for family in [4, 6]:
|
||||
ip_choices = [(None, '---------')]
|
||||
@ -276,12 +282,26 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
|
||||
self.fields['primary_ip6'].widget.attrs['readonly'] = True
|
||||
|
||||
|
||||
class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
|
||||
#
|
||||
# Virtual machine components
|
||||
#
|
||||
|
||||
class VMComponentForm(NetBoxModelForm):
|
||||
virtual_machine = DynamicModelChoiceField(
|
||||
label=_('Virtual machine'),
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
selector=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Disable reassignment of VirtualMachine when editing an existing instance
|
||||
if self.instance.pk:
|
||||
self.fields['virtual_machine'].disabled = True
|
||||
|
||||
|
||||
class VMInterfaceForm(InterfaceCommonForm, VMComponentForm):
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=VMInterface.objects.all(),
|
||||
required=False,
|
||||
@ -348,9 +368,15 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
|
||||
'mode': HTMXSelect(),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Disable reassignment of VirtualMachine when editing an existing instance
|
||||
if self.instance.pk:
|
||||
self.fields['virtual_machine'].disabled = True
|
||||
class VirtualDiskForm(VMComponentForm):
|
||||
|
||||
fieldsets = (
|
||||
(_('Disk'), ('virtual_machine', 'name', 'size', 'description', 'tags')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VirtualDisk
|
||||
fields = [
|
||||
'virtual_machine', 'name', 'size', 'description', 'tags',
|
||||
]
|
||||
|
@ -1,8 +1,9 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from utilities.forms.fields import ExpandableNameField
|
||||
from .model_forms import VMInterfaceForm
|
||||
from .model_forms import VirtualDiskForm, VMInterfaceForm
|
||||
|
||||
__all__ = (
|
||||
'VirtualDiskCreateForm',
|
||||
'VMInterfaceCreateForm',
|
||||
)
|
||||
|
||||
@ -15,3 +16,13 @@ class VMInterfaceCreateForm(VMInterfaceForm):
|
||||
|
||||
class Meta(VMInterfaceForm.Meta):
|
||||
exclude = ('name',)
|
||||
|
||||
|
||||
class VirtualDiskCreateForm(VirtualDiskForm):
|
||||
name = ExpandableNameField(
|
||||
label=_('Name'),
|
||||
)
|
||||
replication_fields = ('name',)
|
||||
|
||||
class Meta(VirtualDiskForm.Meta):
|
||||
exclude = ('name',)
|
||||
|
@ -36,3 +36,9 @@ class VirtualizationQuery(graphene.ObjectType):
|
||||
|
||||
def resolve_vm_interface_list(root, info, **kwargs):
|
||||
return gql_query_optimizer(models.VMInterface.objects.all(), info)
|
||||
|
||||
virtual_disk = ObjectField(VirtualDiskType)
|
||||
virtual_disk_list = ObjectListField(VirtualDiskType)
|
||||
|
||||
def resolve_virtual_disk_list(root, info, **kwargs):
|
||||
return gql_query_optimizer(models.VirtualDisk.objects.all(), info)
|
||||
|
@ -8,6 +8,7 @@ __all__ = (
|
||||
'ClusterType',
|
||||
'ClusterGroupType',
|
||||
'ClusterTypeType',
|
||||
'VirtualDiskType',
|
||||
'VirtualMachineType',
|
||||
'VMInterfaceType',
|
||||
)
|
||||
@ -54,3 +55,14 @@ class VMInterfaceType(IPAddressesMixin, ComponentObjectType):
|
||||
|
||||
def resolve_mode(self, info):
|
||||
return self.mode or None
|
||||
|
||||
|
||||
class VirtualDiskType(ComponentObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.VirtualDisk
|
||||
fields = '__all__'
|
||||
filterset_class = filtersets.VirtualDiskFilterSet
|
||||
|
||||
def resolve_mode(self, info):
|
||||
return self.mode or None
|
||||
|
50
netbox/virtualization/migrations/0038_virtualdisk.py
Normal file
50
netbox/virtualization/migrations/0038_virtualdisk.py
Normal file
@ -0,0 +1,50 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import taggit.managers
|
||||
import utilities.fields
|
||||
import utilities.json
|
||||
import utilities.ordering
|
||||
import utilities.query_functions
|
||||
import utilities.tracking
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0099_cachedvalue_ordering'),
|
||||
('virtualization', '0037_protect_child_interfaces'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='virtualmachine',
|
||||
name='virtual_disk_count',
|
||||
field=utilities.fields.CounterCacheField(default=0, editable=False, to_field='virtual_machine', to_model='virtualization.VirtualDisk'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VirtualDisk',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
|
||||
('name', models.CharField(max_length=64)),
|
||||
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface)),
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
('size', models.PositiveIntegerField()),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
('virtual_machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='virtualization.virtualmachine')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'virtual disk',
|
||||
'verbose_name_plural': 'virtual disks',
|
||||
'ordering': ('virtual_machine', utilities.query_functions.CollateAsChar('_name')),
|
||||
'abstract': False,
|
||||
},
|
||||
bases=(models.Model, utilities.tracking.TrackingModelMixin),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='virtualdisk',
|
||||
constraint=models.UniqueConstraint(fields=('virtual_machine', 'name'), name='virtualization_virtualdisk_unique_virtual_machine_name'),
|
||||
),
|
||||
]
|
@ -2,7 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, Sum
|
||||
from django.db.models.functions import Lower
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -21,6 +21,7 @@ from utilities.tracking import TrackingModelMixin
|
||||
from virtualization.choices import *
|
||||
|
||||
__all__ = (
|
||||
'VirtualDisk',
|
||||
'VirtualMachine',
|
||||
'VMInterface',
|
||||
)
|
||||
@ -130,6 +131,10 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima
|
||||
to_model='virtualization.VMInterface',
|
||||
to_field='virtual_machine'
|
||||
)
|
||||
virtual_disk_count = CounterCacheField(
|
||||
to_model='virtualization.VirtualDisk',
|
||||
to_field='virtual_machine'
|
||||
)
|
||||
|
||||
objects = ConfigContextModelQuerySet.as_manager()
|
||||
|
||||
@ -192,6 +197,17 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima
|
||||
).format(device=self.device, cluster=self.cluster)
|
||||
})
|
||||
|
||||
# Validate aggregate disk size
|
||||
if self.pk:
|
||||
total_disk = self.virtualdisks.aggregate(Sum('size', default=0))['size__sum']
|
||||
if total_disk and self.disk != total_disk:
|
||||
raise ValidationError({
|
||||
'disk': _(
|
||||
"The specified disk size ({size}) must match the aggregate size of assigned virtual disks "
|
||||
"({total_size})."
|
||||
).format(size=self.disk, total_size=total_disk)
|
||||
})
|
||||
|
||||
# Validate primary IP addresses
|
||||
interfaces = self.interfaces.all() if self.pk else None
|
||||
for family in (4, 6):
|
||||
@ -236,11 +252,19 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima
|
||||
return None
|
||||
|
||||
|
||||
class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
|
||||
#
|
||||
# VM components
|
||||
#
|
||||
|
||||
|
||||
class ComponentModel(NetBoxModel):
|
||||
"""
|
||||
An abstract model inherited by any model which has a parent VirtualMachine.
|
||||
"""
|
||||
virtual_machine = models.ForeignKey(
|
||||
to='virtualization.VirtualMachine',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='interfaces'
|
||||
related_name='%(class)ss'
|
||||
)
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
@ -257,6 +281,42 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = ('virtual_machine', CollateAsChar('_name'))
|
||||
constraints = (
|
||||
models.UniqueConstraint(
|
||||
fields=('virtual_machine', 'name'),
|
||||
name='%(app_label)s_%(class)s_unique_virtual_machine_name'
|
||||
),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
objectchange.related_object = self.virtual_machine
|
||||
return objectchange
|
||||
|
||||
@property
|
||||
def parent_object(self):
|
||||
return self.virtual_machine
|
||||
|
||||
|
||||
class VMInterface(ComponentModel, BaseInterface, TrackingModelMixin):
|
||||
virtual_machine = models.ForeignKey(
|
||||
to='virtualization.VirtualMachine',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='interfaces' # Override ComponentModel
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
naturalize_function=naturalize_interface,
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
untagged_vlan = models.ForeignKey(
|
||||
to='ipam.VLAN',
|
||||
on_delete=models.SET_NULL,
|
||||
@ -298,20 +358,10 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
|
||||
related_query_name='vminterface',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('virtual_machine', CollateAsChar('_name'))
|
||||
constraints = (
|
||||
models.UniqueConstraint(
|
||||
fields=('virtual_machine', 'name'),
|
||||
name='%(app_label)s_%(class)s_unique_virtual_machine_name'
|
||||
),
|
||||
)
|
||||
class Meta(ComponentModel.Meta):
|
||||
verbose_name = _('interface')
|
||||
verbose_name_plural = _('interfaces')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('virtualization:vminterface', kwargs={'pk': self.pk})
|
||||
|
||||
@ -359,15 +409,19 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
|
||||
).format(untagged_vlan=self.untagged_vlan)
|
||||
})
|
||||
|
||||
def to_objectchange(self, action):
|
||||
objectchange = super().to_objectchange(action)
|
||||
objectchange.related_object = self.virtual_machine
|
||||
return objectchange
|
||||
|
||||
@property
|
||||
def parent_object(self):
|
||||
return self.virtual_machine
|
||||
|
||||
@property
|
||||
def l2vpn_termination(self):
|
||||
return self.l2vpn_terminations.first()
|
||||
|
||||
|
||||
class VirtualDisk(ComponentModel, TrackingModelMixin):
|
||||
size = models.PositiveIntegerField(
|
||||
verbose_name=_('size (GB)'),
|
||||
)
|
||||
|
||||
class Meta(ComponentModel.Meta):
|
||||
verbose_name = _('virtual disk')
|
||||
verbose_name_plural = _('virtual disks')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('virtualization:virtualdisk', args=[self.pk])
|
||||
|
@ -56,3 +56,13 @@ class VMInterfaceIndex(SearchIndex):
|
||||
('mtu', 2000),
|
||||
)
|
||||
display_attrs = ('virtual_machine', 'description')
|
||||
|
||||
|
||||
@register_search
|
||||
class VirtualDiskIndex(SearchIndex):
|
||||
model = models.VirtualDisk
|
||||
fields = (
|
||||
('name', 100),
|
||||
('description', 500),
|
||||
)
|
||||
display_attrs = ('virtual_machine', 'description')
|
||||
|
16
netbox/virtualization/signals.py
Normal file
16
netbox/virtualization/signals.py
Normal file
@ -0,0 +1,16 @@
|
||||
from django.db.models import Sum
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .models import VirtualDisk, VirtualMachine
|
||||
|
||||
|
||||
@receiver((post_delete, post_save), sender=VirtualDisk)
|
||||
def update_virtualmachine_disk(instance, **kwargs):
|
||||
"""
|
||||
When a VirtualDisk has been modified, update the aggregate disk_size value of its VM.
|
||||
"""
|
||||
vm = instance.virtual_machine
|
||||
VirtualMachine.objects.filter(pk=vm.pk).update(
|
||||
disk=vm.virtualdisks.aggregate(Sum('size'))['size__sum']
|
||||
)
|
@ -4,10 +4,12 @@ from django.utils.translation import gettext_lazy as _
|
||||
from dcim.tables.devices import BaseInterfaceTable
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||
from virtualization.models import VirtualMachine, VMInterface
|
||||
from virtualization.models import VirtualDisk, VirtualMachine, VMInterface
|
||||
|
||||
__all__ = (
|
||||
'VirtualDiskTable',
|
||||
'VirtualMachineTable',
|
||||
'VirtualMachineVirtualDiskTable',
|
||||
'VirtualMachineVMInterfaceTable',
|
||||
'VMInterfaceTable',
|
||||
)
|
||||
@ -84,6 +86,9 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
|
||||
interface_count = tables.Column(
|
||||
verbose_name=_('Interfaces')
|
||||
)
|
||||
virtual_disk_count = tables.Column(
|
||||
verbose_name=_('Virtual Disks')
|
||||
)
|
||||
config_template = tables.Column(
|
||||
verbose_name=_('Config Template'),
|
||||
linkify=True
|
||||
@ -155,3 +160,39 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
|
||||
row_attrs = {
|
||||
'data-name': lambda record: record.name,
|
||||
}
|
||||
|
||||
|
||||
class VirtualDiskTable(NetBoxTable):
|
||||
virtual_machine = tables.Column(
|
||||
verbose_name=_('Virtual Machine'),
|
||||
linkify=True
|
||||
)
|
||||
name = tables.Column(
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='virtualization:virtualdisk_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = VirtualDisk
|
||||
fields = (
|
||||
'pk', 'id', 'virtual_machine', 'name', 'size', 'description', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'virtual_machine', 'size', 'description')
|
||||
row_attrs = {
|
||||
'data-name': lambda record: record.name,
|
||||
}
|
||||
|
||||
|
||||
class VirtualMachineVirtualDiskTable(VirtualDiskTable):
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('edit', 'delete'),
|
||||
)
|
||||
|
||||
class Meta(VirtualDiskTable.Meta):
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'size', 'description', 'tags', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'size', 'description')
|
||||
|
@ -5,9 +5,9 @@ from dcim.choices import InterfaceModeChoices
|
||||
from dcim.models import Site
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.models import VLAN, VRF
|
||||
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
|
||||
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine
|
||||
from virtualization.choices import *
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
from virtualization.models import *
|
||||
|
||||
|
||||
class AppTest(APITestCase):
|
||||
@ -256,10 +256,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
clustertype = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
|
||||
cluster = Cluster.objects.create(name='Test Cluster 1', type=clustertype)
|
||||
virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1')
|
||||
virtualmachine = create_test_virtualmachine('Virtual Machine 1')
|
||||
|
||||
interfaces = (
|
||||
VMInterface(virtual_machine=virtualmachine, name='Interface 1'),
|
||||
@ -336,3 +333,41 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
|
||||
]
|
||||
self.client.delete(self._get_list_url(), data, format='json', **self.header)
|
||||
self.assertEqual(virtual_machine.interfaces.count(), 2) # Child & parent were both deleted
|
||||
|
||||
|
||||
class VirtualDiskTest(APIViewTestCases.APIViewTestCase):
|
||||
model = VirtualDisk
|
||||
brief_fields = ['display', 'id', 'name', 'size', 'url', 'virtual_machine']
|
||||
bulk_update_data = {
|
||||
'size': 888,
|
||||
}
|
||||
graphql_base_name = 'virtual_disk'
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
virtualmachine = create_test_virtualmachine('Virtual Machine 1')
|
||||
|
||||
disks = (
|
||||
VirtualDisk(virtual_machine=virtualmachine, name='Disk 1', size=10),
|
||||
VirtualDisk(virtual_machine=virtualmachine, name='Disk 2', size=20),
|
||||
VirtualDisk(virtual_machine=virtualmachine, name='Disk 3', size=30),
|
||||
)
|
||||
VirtualDisk.objects.bulk_create(disks)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'virtual_machine': virtualmachine.pk,
|
||||
'name': 'Disk 4',
|
||||
'size': 10,
|
||||
},
|
||||
{
|
||||
'virtual_machine': virtualmachine.pk,
|
||||
'name': 'Disk 5',
|
||||
'size': 20,
|
||||
},
|
||||
{
|
||||
'virtual_machine': virtualmachine.pk,
|
||||
'name': 'Disk 6',
|
||||
'size': 30,
|
||||
},
|
||||
]
|
||||
|
@ -6,7 +6,7 @@ from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
|
||||
from virtualization.choices import *
|
||||
from virtualization.filtersets import *
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
from virtualization.models import *
|
||||
|
||||
|
||||
class ClusterTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
@ -534,3 +534,46 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def test_description(self):
|
||||
params = {'description': ['foobar1', 'foobar2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
|
||||
class VirtualDiskTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = VirtualDisk.objects.all()
|
||||
filterset = VirtualDiskFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||
cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type)
|
||||
|
||||
vms = (
|
||||
VirtualMachine(name='Virtual Machine 1', cluster=cluster),
|
||||
VirtualMachine(name='Virtual Machine 2', cluster=cluster),
|
||||
VirtualMachine(name='Virtual Machine 3', cluster=cluster),
|
||||
)
|
||||
VirtualMachine.objects.bulk_create(vms)
|
||||
|
||||
disks = (
|
||||
VirtualDisk(virtual_machine=vms[0], name='Disk 1', size=1, description='A'),
|
||||
VirtualDisk(virtual_machine=vms[1], name='Disk 2', size=2, description='B'),
|
||||
VirtualDisk(virtual_machine=vms[2], name='Disk 3', size=3, description='C'),
|
||||
)
|
||||
VirtualDisk.objects.bulk_create(disks)
|
||||
|
||||
def test_virtual_machine(self):
|
||||
vms = VirtualMachine.objects.all()[:2]
|
||||
params = {'virtual_machine_id': [vms[0].pk, vms[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'virtual_machine': [vms[0].name, vms[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Disk 1', 'Disk 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_size(self):
|
||||
params = {'size': [1, 2]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_description(self):
|
||||
params = {'description': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
@ -90,3 +90,28 @@ class VirtualMachineTestCase(TestCase):
|
||||
# Uniqueness validation for name should ignore case
|
||||
with self.assertRaises(ValidationError):
|
||||
vm2.full_clean()
|
||||
|
||||
def test_disk_size(self):
|
||||
vm = VirtualMachine(
|
||||
cluster=Cluster.objects.first(),
|
||||
name='Virtual Machine 1'
|
||||
)
|
||||
vm.save()
|
||||
vm.refresh_from_db()
|
||||
self.assertEqual(vm.disk, None)
|
||||
|
||||
# Create two VirtualDisks
|
||||
VirtualDisk.objects.create(virtual_machine=vm, name='Virtual Disk 1', size=10)
|
||||
VirtualDisk.objects.create(virtual_machine=vm, name='Virtual Disk 2', size=10)
|
||||
vm.refresh_from_db()
|
||||
self.assertEqual(vm.disk, 20)
|
||||
|
||||
# Delete one VirtualDisk
|
||||
VirtualDisk.objects.first().delete()
|
||||
vm.refresh_from_db()
|
||||
self.assertEqual(vm.disk, 10)
|
||||
|
||||
# Attempt to manually overwrite the aggregate disk size
|
||||
vm.disk = 30
|
||||
with self.assertRaises(ValidationError):
|
||||
vm.full_clean()
|
||||
|
@ -5,9 +5,9 @@ from netaddr import EUI
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from dcim.models import DeviceRole, Platform, Site
|
||||
from ipam.models import VLAN, VRF
|
||||
from utilities.testing import ViewTestCases, create_tags, create_test_device
|
||||
from utilities.testing import ViewTestCases, create_tags, create_test_device, create_test_virtualmachine
|
||||
from virtualization.choices import *
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
from virtualization.models import *
|
||||
|
||||
|
||||
class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
@ -403,3 +403,54 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
}
|
||||
self.client.post(self._get_url('bulk_delete'), data)
|
||||
self.assertEqual(virtual_machine.interfaces.count(), 2) # Child & parent were both deleted
|
||||
|
||||
|
||||
class VirtualDiskTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = VirtualDisk
|
||||
validation_excluded_fields = ('name',)
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
virtualmachine = create_test_virtualmachine('Virtual Machine 1')
|
||||
|
||||
disks = VirtualDisk.objects.bulk_create([
|
||||
VirtualDisk(virtual_machine=virtualmachine, name='Virtual Disk 1', size=10),
|
||||
VirtualDisk(virtual_machine=virtualmachine, name='Virtual Disk 2', size=10),
|
||||
VirtualDisk(virtual_machine=virtualmachine, name='Virtual Disk 3', size=10),
|
||||
])
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'virtual_machine': virtualmachine.pk,
|
||||
'name': 'Virtual Disk X',
|
||||
'size': 20,
|
||||
'description': 'New description',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'virtual_machine': virtualmachine.pk,
|
||||
'name': 'Virtual Disk [4-6]',
|
||||
'size': 10,
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
f"virtual_machine,name,size,description",
|
||||
f"Virtual Machine 1,Disk 4,20,Fourth",
|
||||
f"Virtual Machine 1,Disk 5,20,Fifth",
|
||||
f"Virtual Machine 1,Disk 6,20,Sixth",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
f"id,name,size",
|
||||
f"{disks[0].pk},disk1,20",
|
||||
f"{disks[1].pk},disk2,20",
|
||||
f"{disks[2].pk},disk3,20",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'size': 30,
|
||||
'description': 'New description',
|
||||
}
|
||||
|
@ -48,4 +48,13 @@ urlpatterns = [
|
||||
path('interfaces/<int:pk>/', include(get_model_urls('virtualization', 'vminterface'))),
|
||||
path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_vminterface'),
|
||||
|
||||
# Virtual disks
|
||||
path('disks/', views.VirtualDiskListView.as_view(), name='virtualdisk_list'),
|
||||
path('disks/add/', views.VirtualDiskCreateView.as_view(), name='virtualdisk_add'),
|
||||
path('disks/import/', views.VirtualDiskBulkImportView.as_view(), name='virtualdisk_import'),
|
||||
path('disks/edit/', views.VirtualDiskBulkEditView.as_view(), name='virtualdisk_bulk_edit'),
|
||||
path('disks/rename/', views.VirtualDiskBulkRenameView.as_view(), name='virtualdisk_bulk_rename'),
|
||||
path('disks/delete/', views.VirtualDiskBulkDeleteView.as_view(), name='virtualdisk_bulk_delete'),
|
||||
path('disks/<int:pk>/', include(get_model_urls('virtualization', 'virtualdisk'))),
|
||||
path('virtual-machines/disks/add/', views.VirtualMachineBulkAddVirtualDiskView.as_view(), name='virtualmachine_bulk_add_virtualdisk'),
|
||||
]
|
||||
|
@ -22,7 +22,7 @@ from utilities.query_functions import CollateAsChar
|
||||
from utilities.utils import count_related
|
||||
from utilities.views import ViewTab, register_model_view
|
||||
from . import filtersets, forms, tables
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
from .models import *
|
||||
|
||||
|
||||
#
|
||||
@ -378,6 +378,28 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
|
||||
)
|
||||
|
||||
|
||||
@register_model_view(VirtualMachine, 'disks')
|
||||
class VirtualMachineVirtualDisksView(generic.ObjectChildrenView):
|
||||
queryset = VirtualMachine.objects.all()
|
||||
child_model = VirtualDisk
|
||||
table = tables.VirtualMachineVirtualDiskTable
|
||||
filterset = filtersets.VirtualDiskFilterSet
|
||||
template_name = 'virtualization/virtualmachine/virtual_disks.html'
|
||||
tab = ViewTab(
|
||||
label=_('Virtual Disks'),
|
||||
badge=lambda obj: obj.virtual_disk_count,
|
||||
permission='virtualization.view_virtual_disk',
|
||||
weight=500
|
||||
)
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.virtualdisks.restrict(request.user, 'view').prefetch_related('tags')
|
||||
|
||||
|
||||
@register_model_view(VirtualMachine, 'configcontext', path='config-context')
|
||||
class VirtualMachineConfigContextView(ObjectConfigContextView):
|
||||
queryset = VirtualMachine.objects.annotate_config_context_data()
|
||||
@ -556,6 +578,62 @@ class VMInterfaceBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.VMInterfaceTable
|
||||
|
||||
|
||||
#
|
||||
# Virtual disks
|
||||
#
|
||||
|
||||
class VirtualDiskListView(generic.ObjectListView):
|
||||
queryset = VirtualDisk.objects.all()
|
||||
filterset = filtersets.VirtualDiskFilterSet
|
||||
filterset_form = forms.VirtualDiskFilterForm
|
||||
table = tables.VirtualDiskTable
|
||||
|
||||
|
||||
@register_model_view(VirtualDisk)
|
||||
class VirtualDiskView(generic.ObjectView):
|
||||
queryset = VirtualDisk.objects.all()
|
||||
|
||||
|
||||
class VirtualDiskCreateView(generic.ComponentCreateView):
|
||||
queryset = VirtualDisk.objects.all()
|
||||
form = forms.VirtualDiskCreateForm
|
||||
model_form = forms.VirtualDiskForm
|
||||
|
||||
|
||||
@register_model_view(VirtualDisk, 'edit')
|
||||
class VirtualDiskEditView(generic.ObjectEditView):
|
||||
queryset = VirtualDisk.objects.all()
|
||||
form = forms.VirtualDiskForm
|
||||
|
||||
|
||||
@register_model_view(VirtualDisk, 'delete')
|
||||
class VirtualDiskDeleteView(generic.ObjectDeleteView):
|
||||
queryset = VirtualDisk.objects.all()
|
||||
|
||||
|
||||
class VirtualDiskBulkImportView(generic.BulkImportView):
|
||||
queryset = VirtualDisk.objects.all()
|
||||
model_form = forms.VirtualDiskImportForm
|
||||
|
||||
|
||||
class VirtualDiskBulkEditView(generic.BulkEditView):
|
||||
queryset = VirtualDisk.objects.all()
|
||||
filterset = filtersets.VirtualDiskFilterSet
|
||||
table = tables.VirtualDiskTable
|
||||
form = forms.VirtualDiskBulkEditForm
|
||||
|
||||
|
||||
class VirtualDiskBulkRenameView(generic.BulkRenameView):
|
||||
queryset = VirtualDisk.objects.all()
|
||||
form = forms.VirtualDiskBulkRenameForm
|
||||
|
||||
|
||||
class VirtualDiskBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VirtualDisk.objects.all()
|
||||
filterset = filtersets.VirtualDiskFilterSet
|
||||
table = tables.VirtualDiskTable
|
||||
|
||||
|
||||
#
|
||||
# Bulk Device component creation
|
||||
#
|
||||
@ -572,3 +650,17 @@ class VirtualMachineBulkAddInterfaceView(generic.BulkComponentCreateView):
|
||||
|
||||
def get_required_permission(self):
|
||||
return f'virtualization.add_vminterface'
|
||||
|
||||
|
||||
class VirtualMachineBulkAddVirtualDiskView(generic.BulkComponentCreateView):
|
||||
parent_model = VirtualMachine
|
||||
parent_field = 'virtual_machine'
|
||||
form = forms.VirtualDiskBulkCreateForm
|
||||
queryset = VirtualDisk.objects.all()
|
||||
model_form = forms.VirtualDiskForm
|
||||
filterset = filtersets.VirtualMachineFilterSet
|
||||
table = tables.VirtualMachineTable
|
||||
default_return_url = 'virtualization:virtualmachine_list'
|
||||
|
||||
def get_required_permission(self):
|
||||
return f'virtualization.add_virtualdisk'
|
||||
|
Loading…
Reference in New Issue
Block a user