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.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, OpenApiParameter from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.viewsets import ViewSet from rest_framework.viewsets import ViewSet
from circuits.models import Circuit from circuits.models import Circuit
@ -14,12 +12,11 @@ from dcim import filtersets
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from dcim.models import * from dcim.models import *
from dcim.svg import CableTraceSVG 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 ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.pagination import StripCountAnnotationsPaginator
from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from netbox.constants import NESTED_SERIALIZER_PREFIX from netbox.constants import NESTED_SERIALIZER_PREFIX
@ -390,7 +387,7 @@ class PlatformViewSet(NetBoxModelViewSet):
class DeviceViewSet( class DeviceViewSet(
SequentialBulkCreatesMixin, SequentialBulkCreatesMixin,
ConfigContextQuerySetMixin, ConfigContextQuerySetMixin,
ConfigTemplateRenderMixin, RenderConfigMixin,
NetBoxModelViewSet NetBoxModelViewSet
): ):
queryset = Device.objects.prefetch_related( queryset = Device.objects.prefetch_related(
@ -420,23 +417,6 @@ class DeviceViewSet(
return serializers.DeviceWithConfigContextSerializer 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): class VirtualDeviceContextViewSet(NetBoxModelViewSet):
queryset = VirtualDeviceContext.objects.prefetch_related( queryset = VirtualDeviceContext.objects.prefetch_related(

View File

@ -6,6 +6,7 @@ from rest_framework import status
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.models import ConfigTemplate
from ipam.models import ASN, RIR, VLAN, VRF from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer from netbox.api.serializers import GenericObjectSerializer
from utilities.testing import APITestCase, APIViewTestCases, create_test_device 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) 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): class ModuleTest(APIViewTestCases.APIViewTestCase):
model = Module model = Module

View File

@ -1,10 +1,16 @@
from jinja2.exceptions import TemplateError 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.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST
from netbox.api.renderers import TextRenderer
from .nested_serializers import NestedConfigTemplateSerializer from .nested_serializers import NestedConfigTemplateSerializer
__all__ = ( __all__ = (
'ConfigContextQuerySetMixin', 'ConfigContextQuerySetMixin',
'ConfigTemplateRenderMixin',
'RenderConfigMixin',
) )
@ -31,7 +37,9 @@ class ConfigContextQuerySetMixin:
class ConfigTemplateRenderMixin: class ConfigTemplateRenderMixin:
"""
Provides a method to return a rendered ConfigTemplate as REST API data.
"""
def render_configtemplate(self, request, configtemplate, context): def render_configtemplate(self, request, configtemplate, context):
try: try:
output = configtemplate.render(context=context) output = configtemplate.render(context=context)
@ -50,3 +58,28 @@ class ConfigTemplateRenderMixin:
'configtemplate': template_serializer.data, 'configtemplate': template_serializer.data,
'content': output '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=( items=(
get_model_item('virtualization', 'virtualmachine', _('Virtual Machines')), get_model_item('virtualization', 'virtualmachine', _('Virtual Machines')),
get_model_item('virtualization', 'vminterface', _('Interfaces')), get_model_item('virtualization', 'vminterface', _('Interfaces')),
get_model_item('virtualization', 'virtualdisk', _('Virtual Disks')),
), ),
), ),
MenuGroup( 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> </td>
</tr> </tr>
<tr> <tr>
<th scope="row"><i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}</th> <th scope="row">
<td> <i class="mdi mdi-harddisk"></i> {% trans "Disk Space" %}
{% if object.disk %} </th>
{{ object.disk }} {% trans "GB" context "Abbreviation for gigabyte" %} <td>
{% else %} {% if object.disk %}
{{ ''|placeholder }} {{ object.disk }} {% trans "GB" context "Abbreviation for gigabyte" %}
{% endif %} {% else %}
</td> {{ ''|placeholder }}
{% endif %}
</td>
</tr> </tr>
</table> </table>
</div> </div>
@ -168,6 +170,26 @@
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</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="row">
<div class="col col-md-12"> <div class="col col-md-12">
{% plugin_full_width_page object %} {% plugin_full_width_page object %}

View File

@ -16,9 +16,23 @@
{% endblock %} {% endblock %}
{% block extra_controls %} {% 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"> <div class="dropdown">
<i class="mdi mdi-plus-thick"></i> {% trans "Add Interfaces" %} <button id="add-components" type="button" class="btn btn-sm btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
</a> <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
{% endif %} </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 %} {% 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> </button>
</li> </li>
{% endif %} {% 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> </ul>
</div> </div>
{% endif %} {% endif %}

View File

@ -2,12 +2,13 @@ from drf_spectacular.utils import extend_schema_serializer
from rest_framework import serializers from rest_framework import serializers
from netbox.api.serializers import WritableNestedSerializer from netbox.api.serializers import WritableNestedSerializer
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import *
__all__ = [ __all__ = [
'NestedClusterGroupSerializer', 'NestedClusterGroupSerializer',
'NestedClusterSerializer', 'NestedClusterSerializer',
'NestedClusterTypeSerializer', 'NestedClusterTypeSerializer',
'NestedVirtualDiskSerializer',
'NestedVMInterfaceSerializer', 'NestedVMInterfaceSerializer',
'NestedVirtualMachineSerializer', 'NestedVirtualMachineSerializer',
] ]
@ -72,3 +73,12 @@ class NestedVMInterfaceSerializer(WritableNestedSerializer):
class Meta: class Meta:
model = VMInterface model = VMInterface
fields = ['id', 'url', 'display', 'virtual_machine', 'name'] 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 netbox.api.serializers import NetBoxModelSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer from tenancy.api.nested_serializers import NestedTenantSerializer
from virtualization.choices import * 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 * from .nested_serializers import *
@ -84,6 +84,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
# Counter fields # Counter fields
interface_count = serializers.IntegerField(read_only=True) interface_count = serializers.IntegerField(read_only=True)
virtual_disk_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
@ -91,7 +92,7 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
'interface_count', 'interface_count', 'virtual_disk_count',
] ]
validators = [] validators = []
@ -104,7 +105,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', '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)) @extend_schema_field(serializers.JSONField(allow_null=True))
@ -159,3 +160,19 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
}) })
return super().validate(data) 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 # VirtualMachines
router.register('virtual-machines', views.VirtualMachineViewSet) router.register('virtual-machines', views.VirtualMachineViewSet)
router.register('interfaces', views.VMInterfaceViewSet) router.register('interfaces', views.VMInterfaceViewSet)
router.register('virtual-disks', views.VirtualDiskViewSet)
app_name = 'virtualization-api' app_name = 'virtualization-api'
urlpatterns = router.urls urlpatterns = router.urls

View File

@ -1,12 +1,12 @@
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from dcim.models import Device 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 netbox.api.viewsets import NetBoxModelViewSet
from utilities.query_functions import CollateAsChar from utilities.query_functions import CollateAsChar
from utilities.utils import count_related from utilities.utils import count_related
from virtualization import filtersets from virtualization import filtersets
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import *
from . import serializers from . import serializers
@ -53,9 +53,10 @@ class ClusterViewSet(NetBoxModelViewSet):
# Virtual machines # Virtual machines
# #
class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet):
queryset = VirtualMachine.objects.prefetch_related( 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 filterset_class = filtersets.VirtualMachineFilterSet
@ -92,3 +93,12 @@ class VMInterfaceViewSet(NetBoxModelViewSet):
def get_bulk_destroy_queryset(self): def get_bulk_destroy_queryset(self):
# Ensure child interfaces are deleted prior to their parents # Ensure child interfaces are deleted prior to their parents
return self.get_queryset().order_by('virtual_machine', 'parent', CollateAsChar('_name')) 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' name = 'virtualization'
def ready(self): def ready(self):
from . import search from . import search, signals
from .models import VirtualMachine from .models import VirtualMachine
from utilities.counters import connect_counters 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 tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
from .choices import * from .choices import *
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from .models import *
__all__ = ( __all__ = (
'ClusterFilterSet', 'ClusterFilterSet',
'ClusterGroupFilterSet', 'ClusterGroupFilterSet',
'ClusterTypeFilterSet', 'ClusterTypeFilterSet',
'VirtualDiskFilterSet',
'VirtualMachineFilterSet', 'VirtualMachineFilterSet',
'VMInterfaceFilterSet', 'VMInterfaceFilterSet',
) )
@ -305,3 +306,29 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
Q(name__icontains=value) | Q(name__icontains=value) |
Q(description__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 import BootstrapMixin, form_from_model
from utilities.forms.fields import ExpandableNameField from utilities.forms.fields import ExpandableNameField
from virtualization.models import VMInterface, VirtualMachine from virtualization.models import VirtualDisk, VMInterface, VirtualMachine
__all__ = ( __all__ = (
'VirtualDiskBulkCreateForm',
'VMInterfaceBulkCreateForm', 'VMInterfaceBulkCreateForm',
) )
@ -30,3 +31,10 @@ class VMInterfaceBulkCreateForm(
VirtualMachineBulkAddComponentForm VirtualMachineBulkAddComponentForm
): ):
replication_fields = ('name',) replication_fields = ('name',)
class VirtualDiskBulkCreateForm(
form_from_model(VirtualDisk, ['size', 'description', 'tags']),
VirtualMachineBulkAddComponentForm
):
replication_fields = ('name',)

View File

@ -18,6 +18,8 @@ __all__ = (
'ClusterBulkEditForm', 'ClusterBulkEditForm',
'ClusterGroupBulkEditForm', 'ClusterGroupBulkEditForm',
'ClusterTypeBulkEditForm', 'ClusterTypeBulkEditForm',
'VirtualDiskBulkEditForm',
'VirtualDiskBulkRenameForm',
'VirtualMachineBulkEditForm', 'VirtualMachineBulkEditForm',
'VMInterfaceBulkEditForm', 'VMInterfaceBulkEditForm',
'VMInterfaceBulkRenameForm', 'VMInterfaceBulkRenameForm',
@ -315,3 +317,35 @@ class VMInterfaceBulkRenameForm(BulkRenameForm):
queryset=VMInterface.objects.all(), queryset=VMInterface.objects.all(),
widget=forms.MultipleHiddenInput() 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', 'ClusterImportForm',
'ClusterGroupImportForm', 'ClusterGroupImportForm',
'ClusterTypeImportForm', 'ClusterTypeImportForm',
'VirtualDiskImportForm',
'VirtualMachineImportForm', 'VirtualMachineImportForm',
'VMInterfaceImportForm', 'VMInterfaceImportForm',
) )
@ -199,3 +200,17 @@ class VMInterfaceImportForm(NetBoxModelImportForm):
return True return True
else: else:
return self.cleaned_data['enabled'] 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', 'ClusterFilterForm',
'ClusterGroupFilterForm', 'ClusterGroupFilterForm',
'ClusterTypeFilterForm', 'ClusterTypeFilterForm',
'VirtualDiskFilterForm',
'VirtualMachineFilterForm', 'VirtualMachineFilterForm',
'VMInterfaceFilterForm', 'VMInterfaceFilterForm',
) )
@ -221,3 +222,23 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
label=_('L2VPN') label=_('L2VPN')
) )
tag = TagFilterField(model) 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', 'ClusterGroupForm',
'ClusterRemoveDevicesForm', 'ClusterRemoveDevicesForm',
'ClusterTypeForm', 'ClusterTypeForm',
'VirtualDiskForm',
'VirtualMachineForm', 'VirtualMachineForm',
'VMInterfaceForm', 'VMInterfaceForm',
) )
@ -240,6 +241,11 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
if self.instance.pk: 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 # Compile list of choices for primary IPv4 and IPv6 addresses
for family in [4, 6]: for family in [4, 6]:
ip_choices = [(None, '---------')] ip_choices = [(None, '---------')]
@ -276,12 +282,26 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
self.fields['primary_ip6'].widget.attrs['readonly'] = True self.fields['primary_ip6'].widget.attrs['readonly'] = True
class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): #
# Virtual machine components
#
class VMComponentForm(NetBoxModelForm):
virtual_machine = DynamicModelChoiceField( virtual_machine = DynamicModelChoiceField(
label=_('Virtual machine'), label=_('Virtual machine'),
queryset=VirtualMachine.objects.all(), queryset=VirtualMachine.objects.all(),
selector=True 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( parent = DynamicModelChoiceField(
queryset=VMInterface.objects.all(), queryset=VMInterface.objects.all(),
required=False, required=False,
@ -348,9 +368,15 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
'mode': HTMXSelect(), 'mode': HTMXSelect(),
} }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Disable reassignment of VirtualMachine when editing an existing instance class VirtualDiskForm(VMComponentForm):
if self.instance.pk:
self.fields['virtual_machine'].disabled = True 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 django.utils.translation import gettext_lazy as _
from utilities.forms.fields import ExpandableNameField from utilities.forms.fields import ExpandableNameField
from .model_forms import VMInterfaceForm from .model_forms import VirtualDiskForm, VMInterfaceForm
__all__ = ( __all__ = (
'VirtualDiskCreateForm',
'VMInterfaceCreateForm', 'VMInterfaceCreateForm',
) )
@ -15,3 +16,13 @@ class VMInterfaceCreateForm(VMInterfaceForm):
class Meta(VMInterfaceForm.Meta): class Meta(VMInterfaceForm.Meta):
exclude = ('name',) 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): def resolve_vm_interface_list(root, info, **kwargs):
return gql_query_optimizer(models.VMInterface.objects.all(), info) 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', 'ClusterType',
'ClusterGroupType', 'ClusterGroupType',
'ClusterTypeType', 'ClusterTypeType',
'VirtualDiskType',
'VirtualMachineType', 'VirtualMachineType',
'VMInterfaceType', 'VMInterfaceType',
) )
@ -54,3 +55,14 @@ class VMInterfaceType(IPAddressesMixin, ComponentObjectType):
def resolve_mode(self, info): def resolve_mode(self, info):
return self.mode or None 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.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models 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.db.models.functions import Lower
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -21,6 +21,7 @@ from utilities.tracking import TrackingModelMixin
from virtualization.choices import * from virtualization.choices import *
__all__ = ( __all__ = (
'VirtualDisk',
'VirtualMachine', 'VirtualMachine',
'VMInterface', 'VMInterface',
) )
@ -130,6 +131,10 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima
to_model='virtualization.VMInterface', to_model='virtualization.VMInterface',
to_field='virtual_machine' to_field='virtual_machine'
) )
virtual_disk_count = CounterCacheField(
to_model='virtualization.VirtualDisk',
to_field='virtual_machine'
)
objects = ConfigContextModelQuerySet.as_manager() objects = ConfigContextModelQuerySet.as_manager()
@ -192,6 +197,17 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima
).format(device=self.device, cluster=self.cluster) ).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 # Validate primary IP addresses
interfaces = self.interfaces.all() if self.pk else None interfaces = self.interfaces.all() if self.pk else None
for family in (4, 6): for family in (4, 6):
@ -236,11 +252,19 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima
return None 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( virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine', to='virtualization.VirtualMachine',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='interfaces' related_name='%(class)ss'
) )
name = models.CharField( name = models.CharField(
verbose_name=_('name'), verbose_name=_('name'),
@ -257,6 +281,42 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
max_length=200, max_length=200,
blank=True 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( untagged_vlan = models.ForeignKey(
to='ipam.VLAN', to='ipam.VLAN',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -298,20 +358,10 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
related_query_name='vminterface', related_query_name='vminterface',
) )
class Meta: class Meta(ComponentModel.Meta):
ordering = ('virtual_machine', CollateAsChar('_name'))
constraints = (
models.UniqueConstraint(
fields=('virtual_machine', 'name'),
name='%(app_label)s_%(class)s_unique_virtual_machine_name'
),
)
verbose_name = _('interface') verbose_name = _('interface')
verbose_name_plural = _('interfaces') verbose_name_plural = _('interfaces')
def __str__(self):
return self.name
def get_absolute_url(self): def get_absolute_url(self):
return reverse('virtualization:vminterface', kwargs={'pk': self.pk}) return reverse('virtualization:vminterface', kwargs={'pk': self.pk})
@ -359,15 +409,19 @@ class VMInterface(NetBoxModel, BaseInterface, TrackingModelMixin):
).format(untagged_vlan=self.untagged_vlan) ).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 @property
def l2vpn_termination(self): def l2vpn_termination(self):
return self.l2vpn_terminations.first() 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), ('mtu', 2000),
) )
display_attrs = ('virtual_machine', 'description') 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 dcim.tables.devices import BaseInterfaceTable
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from virtualization.models import VirtualMachine, VMInterface from virtualization.models import VirtualDisk, VirtualMachine, VMInterface
__all__ = ( __all__ = (
'VirtualDiskTable',
'VirtualMachineTable', 'VirtualMachineTable',
'VirtualMachineVirtualDiskTable',
'VirtualMachineVMInterfaceTable', 'VirtualMachineVMInterfaceTable',
'VMInterfaceTable', 'VMInterfaceTable',
) )
@ -84,6 +86,9 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
interface_count = tables.Column( interface_count = tables.Column(
verbose_name=_('Interfaces') verbose_name=_('Interfaces')
) )
virtual_disk_count = tables.Column(
verbose_name=_('Virtual Disks')
)
config_template = tables.Column( config_template = tables.Column(
verbose_name=_('Config Template'), verbose_name=_('Config Template'),
linkify=True linkify=True
@ -155,3 +160,39 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable):
row_attrs = { row_attrs = {
'data-name': lambda record: record.name, '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.choices import InterfaceModeChoices
from dcim.models import Site from dcim.models import Site
from extras.models import ConfigTemplate
from ipam.models import VLAN, VRF 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.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import *
class AppTest(APITestCase): class AppTest(APITestCase):
@ -228,6 +229,22 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) 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): class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
model = VMInterface model = VMInterface
@ -239,10 +256,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
virtualmachine = create_test_virtualmachine('Virtual Machine 1')
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')
interfaces = ( interfaces = (
VMInterface(virtual_machine=virtualmachine, name='Interface 1'), 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.client.delete(self._get_list_url(), data, format='json', **self.header)
self.assertEqual(virtual_machine.interfaces.count(), 2) # Child & parent were both deleted 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 utilities.testing import ChangeLoggedFilterSetTests, create_test_device
from virtualization.choices import * from virtualization.choices import *
from virtualization.filtersets import * from virtualization.filtersets import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import *
class ClusterTypeTestCase(TestCase, ChangeLoggedFilterSetTests): class ClusterTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
@ -534,3 +534,46 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_description(self): def test_description(self):
params = {'description': ['foobar1', 'foobar2']} params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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 # Uniqueness validation for name should ignore case
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
vm2.full_clean() 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.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Platform, Site from dcim.models import DeviceRole, Platform, Site
from ipam.models import VLAN, VRF 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.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import *
class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@ -403,3 +403,54 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
} }
self.client.post(self._get_url('bulk_delete'), data) self.client.post(self._get_url('bulk_delete'), data)
self.assertEqual(virtual_machine.interfaces.count(), 2) # Child & parent were both deleted 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('interfaces/<int:pk>/', include(get_model_urls('virtualization', 'vminterface'))),
path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_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.utils import count_related
from utilities.views import ViewTab, register_model_view from utilities.views import ViewTab, register_model_view
from . import filtersets, forms, tables 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') @register_model_view(VirtualMachine, 'configcontext', path='config-context')
class VirtualMachineConfigContextView(ObjectConfigContextView): class VirtualMachineConfigContextView(ObjectConfigContextView):
queryset = VirtualMachine.objects.annotate_config_context_data() queryset = VirtualMachine.objects.annotate_config_context_data()
@ -556,6 +578,62 @@ class VMInterfaceBulkDeleteView(generic.BulkDeleteView):
table = tables.VMInterfaceTable 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 # Bulk Device component creation
# #
@ -572,3 +650,17 @@ class VirtualMachineBulkAddInterfaceView(generic.BulkComponentCreateView):
def get_required_permission(self): def get_required_permission(self):
return f'virtualization.add_vminterface' 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'