diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py
index 961fd2035..43cf3f869 100644
--- a/netbox/netbox/navigation/menu.py
+++ b/netbox/netbox/navigation/menu.py
@@ -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(
diff --git a/netbox/templates/virtualization/virtualdisk.html b/netbox/templates/virtualization/virtualdisk.html
new file mode 100644
index 000000000..821e58796
--- /dev/null
+++ b/netbox/templates/virtualization/virtualdisk.html
@@ -0,0 +1,59 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+ {{ block.super }}
+
{% plugin_full_width_page object %}
diff --git a/netbox/templates/virtualization/virtualmachine/base.html b/netbox/templates/virtualization/virtualmachine/base.html
index 8a1d68ed6..a147ef944 100644
--- a/netbox/templates/virtualization/virtualmachine/base.html
+++ b/netbox/templates/virtualization/virtualmachine/base.html
@@ -16,9 +16,23 @@
{% endblock %}
{% block extra_controls %}
- {% if perms.virtualization.add_vminterface %}
-
- {% trans "Add Interfaces" %}
-
- {% endif %}
+
+
+
+
+
+
{% endblock %}
diff --git a/netbox/templates/virtualization/virtualmachine/virtual_disks.html b/netbox/templates/virtualization/virtualmachine/virtual_disks.html
new file mode 100644
index 000000000..a947f9824
--- /dev/null
+++ b/netbox/templates/virtualization/virtualmachine/virtual_disks.html
@@ -0,0 +1,14 @@
+{% extends 'generic/object_children.html' %}
+{% load helpers %}
+{% load i18n %}
+
+{% block bulk_edit_controls %}
+ {{ block.super }}
+ {% if 'bulk_rename' in actions %}
+
+ {% endif %}
+{% endblock bulk_edit_controls %}
diff --git a/netbox/templates/virtualization/virtualmachine_list.html b/netbox/templates/virtualization/virtualmachine_list.html
index bbb3ddab4..8c5e81256 100644
--- a/netbox/templates/virtualization/virtualmachine_list.html
+++ b/netbox/templates/virtualization/virtualmachine_list.html
@@ -15,6 +15,13 @@
{% endif %}
+ {% if perms.virtualization.add_virtualdisk %}
+
+
+
+ {% endif %}
{% endif %}
diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py
index 8c3f57c1d..afb7e39a1 100644
--- a/netbox/virtualization/api/nested_serializers.py
+++ b/netbox/virtualization/api/nested_serializers.py
@@ -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']
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index c9fa559aa..95b2152a5 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -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',
+ ]
diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py
index 2ceeb8ce6..ce71605a1 100644
--- a/netbox/virtualization/api/urls.py
+++ b/netbox/virtualization/api/urls.py
@@ -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
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index 2b28505ab..3ba2bb97f 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -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']
diff --git a/netbox/virtualization/apps.py b/netbox/virtualization/apps.py
index 8db943ea1..f0af9a163 100644
--- a/netbox/virtualization/apps.py
+++ b/netbox/virtualization/apps.py
@@ -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
diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py
index b23808b31..351166260 100644
--- a/netbox/virtualization/filtersets.py
+++ b/netbox/virtualization/filtersets.py
@@ -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)
+ )
diff --git a/netbox/virtualization/forms/bulk_create.py b/netbox/virtualization/forms/bulk_create.py
index 7153453ec..a4ad867d4 100644
--- a/netbox/virtualization/forms/bulk_create.py
+++ b/netbox/virtualization/forms/bulk_create.py
@@ -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',)
diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py
index a33ffac53..72990ec76 100644
--- a/netbox/virtualization/forms/bulk_edit.py
+++ b/netbox/virtualization/forms/bulk_edit.py
@@ -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()
+ )
diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py
index 04fe2d7ae..5d44ddceb 100644
--- a/netbox/virtualization/forms/bulk_import.py
+++ b/netbox/virtualization/forms/bulk_import.py
@@ -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'
+ )
diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py
index 99ac0cb77..5eb3fea1c 100644
--- a/netbox/virtualization/forms/filtersets.py
+++ b/netbox/virtualization/forms/filtersets.py
@@ -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)
diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py
index bca6a1ec6..cbbf5ea66 100644
--- a/netbox/virtualization/forms/model_forms.py
+++ b/netbox/virtualization/forms/model_forms.py
@@ -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',
+ ]
diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py
index 3ea374039..2f6844a5c 100644
--- a/netbox/virtualization/forms/object_create.py
+++ b/netbox/virtualization/forms/object_create.py
@@ -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',)
diff --git a/netbox/virtualization/graphql/schema.py b/netbox/virtualization/graphql/schema.py
index 88e6aac64..1461faaeb 100644
--- a/netbox/virtualization/graphql/schema.py
+++ b/netbox/virtualization/graphql/schema.py
@@ -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)
diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py
index 96b0fc875..9b97e1dc9 100644
--- a/netbox/virtualization/graphql/types.py
+++ b/netbox/virtualization/graphql/types.py
@@ -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
diff --git a/netbox/virtualization/migrations/0038_virtualdisk.py b/netbox/virtualization/migrations/0038_virtualdisk.py
new file mode 100644
index 000000000..59d45c975
--- /dev/null
+++ b/netbox/virtualization/migrations/0038_virtualdisk.py
@@ -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'),
+ ),
+ ]
diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py
index eb6c2a8b0..705419186 100644
--- a/netbox/virtualization/models/virtualmachines.py
+++ b/netbox/virtualization/models/virtualmachines.py
@@ -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])
diff --git a/netbox/virtualization/search.py b/netbox/virtualization/search.py
index 12174dda4..9e67a0af2 100644
--- a/netbox/virtualization/search.py
+++ b/netbox/virtualization/search.py
@@ -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')
diff --git a/netbox/virtualization/signals.py b/netbox/virtualization/signals.py
new file mode 100644
index 000000000..06f172179
--- /dev/null
+++ b/netbox/virtualization/signals.py
@@ -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']
+ )
diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py
index f8473df1e..88627462a 100644
--- a/netbox/virtualization/tables/virtualmachines.py
+++ b/netbox/virtualization/tables/virtualmachines.py
@@ -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')
diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py
index b33f3afe9..819ce54e4 100644
--- a/netbox/virtualization/tests/test_api.py
+++ b/netbox/virtualization/tests/test_api.py
@@ -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,
+ },
+ ]
diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py
index e6fe90297..8e2e723bd 100644
--- a/netbox/virtualization/tests/test_filtersets.py
+++ b/netbox/virtualization/tests/test_filtersets.py
@@ -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)
diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py
index 782b9f07f..c94ff930e 100644
--- a/netbox/virtualization/tests/test_models.py
+++ b/netbox/virtualization/tests/test_models.py
@@ -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()
diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py
index f47c386e9..ed6bef1e4 100644
--- a/netbox/virtualization/tests/test_views.py
+++ b/netbox/virtualization/tests/test_views.py
@@ -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',
+ }
diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py
index 9e5d5a670..78f88260a 100644
--- a/netbox/virtualization/urls.py
+++ b/netbox/virtualization/urls.py
@@ -48,4 +48,13 @@ urlpatterns = [
path('interfaces/
/', 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//', include(get_model_urls('virtualization', 'virtualdisk'))),
+ path('virtual-machines/disks/add/', views.VirtualMachineBulkAddVirtualDiskView.as_view(), name='virtualmachine_bulk_add_virtualdisk'),
]
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index e8782243f..6019fc227 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -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'