Merge branch 'feature' into 14132-event-refactor-2

This commit is contained in:
Arthur 2023-11-20 08:00:15 -08:00
commit bfc9a23e98
34 changed files with 876 additions and 88 deletions

View File

@ -3,10 +3,8 @@ from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.decorators import action
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.viewsets import ViewSet
from circuits.models import Circuit
@ -14,12 +12,11 @@ from dcim import filtersets
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from dcim.models import *
from dcim.svg import CableTraceSVG
from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
from ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator
from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from netbox.constants import NESTED_SERIALIZER_PREFIX
@ -390,7 +387,7 @@ class PlatformViewSet(NetBoxModelViewSet):
class DeviceViewSet(
SequentialBulkCreatesMixin,
ConfigContextQuerySetMixin,
ConfigTemplateRenderMixin,
RenderConfigMixin,
NetBoxModelViewSet
):
queryset = Device.objects.prefetch_related(
@ -420,23 +417,6 @@ class DeviceViewSet(
return serializers.DeviceWithConfigContextSerializer
@action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
def render_config(self, request, pk):
"""
Resolve and render the preferred ConfigTemplate for this Device.
"""
device = self.get_object()
configtemplate = device.get_config_template()
if not configtemplate:
return Response({'error': 'No config template found for this device.'}, status=HTTP_400_BAD_REQUEST)
# Compile context data
context_data = device.get_config_context()
context_data.update(request.data)
context_data.update({'device': device})
return self.render_configtemplate(request, configtemplate, context_data)
class VirtualDeviceContextViewSet(NetBoxModelViewSet):
queryset = VirtualDeviceContext.objects.prefetch_related(

View File

@ -6,6 +6,7 @@ from rest_framework import status
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.models import ConfigTemplate
from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
@ -1265,6 +1266,22 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_render_config(self):
configtemplate = ConfigTemplate.objects.create(
name='Config Template 1',
template_code='Config for device {{ device.name }}'
)
device = Device.objects.first()
device.config_template = configtemplate
device.save()
self.add_permissions('dcim.add_device')
url = reverse('dcim-api:device-detail', kwargs={'pk': device.pk}) + 'render-config/'
response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['content'], f'Config for device {device.name}')
class ModuleTest(APIViewTestCases.APIViewTestCase):
model = Module

View File

@ -1,10 +1,16 @@
from jinja2.exceptions import TemplateError
from rest_framework.decorators import action
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST
from netbox.api.renderers import TextRenderer
from .nested_serializers import NestedConfigTemplateSerializer
__all__ = (
'ConfigContextQuerySetMixin',
'ConfigTemplateRenderMixin',
'RenderConfigMixin',
)
@ -31,7 +37,9 @@ class ConfigContextQuerySetMixin:
class ConfigTemplateRenderMixin:
"""
Provides a method to return a rendered ConfigTemplate as REST API data.
"""
def render_configtemplate(self, request, configtemplate, context):
try:
output = configtemplate.render(context=context)
@ -50,3 +58,28 @@ class ConfigTemplateRenderMixin:
'configtemplate': template_serializer.data,
'content': output
})
class RenderConfigMixin(ConfigTemplateRenderMixin):
"""
Provides a /render-config/ endpoint for REST API views whose model may have a ConfigTemplate assigned.
"""
@action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
def render_config(self, request, pk):
"""
Resolve and render the preferred ConfigTemplate for this Device.
"""
instance = self.get_object()
object_type = instance._meta.model_name
configtemplate = instance.get_config_template()
if not configtemplate:
return Response({
'error': f'No config template found for this {object_type}.'
}, status=HTTP_400_BAD_REQUEST)
# Compile context data
context_data = instance.get_config_context()
context_data.update(request.data)
context_data.update({object_type: instance})
return self.render_configtemplate(request, configtemplate, context_data)

View File

@ -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(

View 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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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']

View File

@ -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',
]

View File

@ -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

View File

@ -1,12 +1,12 @@
from rest_framework.routers import APIRootView
from dcim.models import Device
from extras.api.mixins import ConfigContextQuerySetMixin
from extras.api.mixins import ConfigContextQuerySetMixin, RenderConfigMixin
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
@ -53,9 +53,10 @@ class ClusterViewSet(NetBoxModelViewSet):
# Virtual machines
#
class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet):
queryset = VirtualMachine.objects.prefetch_related(
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', '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']

View File

@ -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

View File

@ -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)
)

View File

@ -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',)

View File

@ -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()
)

View File

@ -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'
)

View File

@ -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)

View File

@ -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',
]

View File

@ -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',)

View File

@ -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)

View File

@ -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

View 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'),
),
]

View File

@ -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])

View File

@ -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')

View 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']
)

View File

@ -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')

View File

@ -3,10 +3,11 @@ from rest_framework import status
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):
@ -228,6 +229,22 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_render_config(self):
configtemplate = ConfigTemplate.objects.create(
name='Config Template 1',
template_code='Config for virtual machine {{ virtualmachine.name }}'
)
vm = VirtualMachine.objects.first()
vm.config_template = configtemplate
vm.save()
self.add_permissions('virtualization.add_virtualmachine')
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': vm.pk}) + 'render-config/'
response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['content'], f'Config for virtual machine {vm.name}')
class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
model = VMInterface
@ -239,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'),
@ -319,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,
},
]

View File

@ -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)

View File

@ -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()

View File

@ -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',
}

View File

@ -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'),
]

View File

@ -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'