From a9ff808d04b4fe5a32bf9d2ae4fa130660a69fa9 Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Thu, 26 Mar 2026 19:16:44 +0100 Subject: [PATCH] feat(virtualization): Add Virtual Machine Type model Introduce `VirtualMachineType` to classify virtual machines and apply default platform, vCPU, and memory values when creating a VM. This adds the new model and its relationship to `VirtualMachine`, and wires it through forms, filtersets, tables, views, the REST API, GraphQL, navigation, search, documentation, and tests. Explicit values set on a virtual machine continue to take precedence, and changes to a type do not retroactively update existing VMs. --- docs/development/models.md | 2 + docs/features/virtualization.md | 30 +- docs/models/virtualization/virtualmachine.md | 12 +- .../virtualization/virtualmachinetype.md | 27 ++ mkdocs.yml | 3 +- netbox/extras/tests/test_filtersets.py | 1 + netbox/netbox/navigation/menu.py | 6 + .../virtualization/virtualmachinetype.html | 10 + .../api/serializers_/virtualmachines.py | 39 +- netbox/virtualization/api/urls.py | 3 + netbox/virtualization/api/views.py | 14 + netbox/virtualization/apps.py | 4 +- netbox/virtualization/filtersets.py | 52 +++ netbox/virtualization/forms/bulk_edit.py | 41 +- netbox/virtualization/forms/bulk_import.py | 35 +- netbox/virtualization/forms/filtersets.py | 54 ++- netbox/virtualization/forms/model_forms.py | 45 ++- netbox/virtualization/graphql/filters.py | 39 +- netbox/virtualization/graphql/schema.py | 5 +- netbox/virtualization/graphql/types.py | 15 + .../migrations/0054_virtualmachinetype.py | 106 +++++ .../virtualization/models/virtualmachines.py | 109 +++++- netbox/virtualization/search.py | 12 + .../virtualization/tables/virtualmachines.py | 47 ++- netbox/virtualization/tests/test_api.py | 246 ++++++++++-- .../virtualization/tests/test_filtersets.py | 148 ++++++- netbox/virtualization/tests/test_models.py | 364 +++++++++++++++++- netbox/virtualization/tests/test_views.py | 207 ++++++++-- netbox/virtualization/ui/panels.py | 33 ++ netbox/virtualization/urls.py | 3 + netbox/virtualization/views.py | 74 ++++ 31 files changed, 1655 insertions(+), 131 deletions(-) create mode 100644 docs/models/virtualization/virtualmachinetype.md create mode 100644 netbox/templates/virtualization/virtualmachinetype.html create mode 100644 netbox/virtualization/migrations/0054_virtualmachinetype.py diff --git a/docs/development/models.md b/docs/development/models.md index b6ff436a0..aabca514d 100644 --- a/docs/development/models.md +++ b/docs/development/models.md @@ -74,6 +74,7 @@ These are considered the "core" application models which are used to model netwo * [tenancy.Tenant](../models/tenancy/tenant.md) * [virtualization.Cluster](../models/virtualization/cluster.md) * [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md) +* [virtualization.VirtualMachineType](../models/virtualization/virtualmachinetype.md) * [vpn.IKEPolicy](../models/vpn/ikepolicy.md) * [vpn.IKEProposal](../models/vpn/ikeproposal.md) * [vpn.IPSecPolicy](../models/vpn/ipsecpolicy.md) @@ -93,6 +94,7 @@ Organization models are used to organize and classify primary models. * [dcim.DeviceRole](../models/dcim/devicerole.md) * [dcim.Manufacturer](../models/dcim/manufacturer.md) * [dcim.Platform](../models/dcim/platform.md) +* [dcim.RackGroup](../models/dcim/rackgroup.md) * [dcim.RackRole](../models/dcim/rackrole.md) * [ipam.ASNRange](../models/ipam/asnrange.md) * [ipam.RIR](../models/ipam/rir.md) diff --git a/docs/features/virtualization.md b/docs/features/virtualization.md index 5259ce02f..4a00c6ced 100644 --- a/docs/features/virtualization.md +++ b/docs/features/virtualization.md @@ -5,27 +5,37 @@ Virtual machines, clusters, and standalone hypervisors can be modeled in NetBox ```mermaid flowchart TD ClusterGroup & ClusterType --> Cluster - Cluster --> VirtualMachine + VirtualMachineType --> VirtualMachine Device --> VirtualMachine + Cluster --> VirtualMachine Platform --> VirtualMachine VirtualMachine --> VMInterface -click Cluster "../../models/virtualization/cluster/" -click ClusterGroup "../../models/virtualization/clustergroup/" -click ClusterType "../../models/virtualization/clustertype/" -click Device "../../models/dcim/device/" -click Platform "../../models/dcim/platform/" -click VirtualMachine "../../models/virtualization/virtualmachine/" -click VMInterface "../../models/virtualization/vminterface/" + click Cluster "../../models/virtualization/cluster/" + click ClusterGroup "../../models/virtualization/clustergroup/" + click ClusterType "../../models/virtualization/clustertype/" + click VirtualMachineType "../../models/virtualization/virtualmachinetype/" + click Device "../../models/dcim/device/" + click Platform "../../models/dcim/platform/" + click VirtualMachine "../../models/virtualization/virtualmachine/" + click VMInterface "../../models/virtualization/vminterface/" ``` ## Clusters -A cluster is one or more physical host devices on which virtual machines can run. Each cluster must have a type and operational status, and may be assigned to a group. (Both types and groups are user-defined.) Each cluster may designate one or more devices as hosts, however this is optional. +A cluster is one or more physical host devices on which virtual machines can run. + +Each cluster must have a type and operational status, and may be assigned to a group. (Both types and groups are user-defined.) Each cluster may designate one or more devices as hosts, however this is optional. + +## Virtual Machine Types + +A virtual machine type provides reusable classification for virtual machines and can define create-time defaults for platform, vCPUs, and memory. This is useful when multiple virtual machines share a common sizing or profile while still allowing per-instance overrides after creation. ## Virtual Machines -A virtual machine is a virtualized compute instance. These behave in NetBox very similarly to device objects, but without any physical attributes. For example, a VM may have interfaces assigned to it with IP addresses and VLANs, however its interfaces cannot be connected via cables (because they are virtual). Each VM may define its compute, memory, and storage resources as well. +A virtual machine is a virtualized compute instance. These behave in NetBox very similarly to device objects, but without any physical attributes. + +For example, a VM may have interfaces assigned to it with IP addresses and VLANs, however its interfaces cannot be connected via cables (because they are virtual). Each VM may define its compute, memory, and storage resources as well. A VM can optionally be assigned a [virtual machine type](../models/virtualization/virtualmachinetype.md) to classify it and provide default values for selected attributes at creation time. A VM can be placed in one of three ways: diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md index 61a737552..f8786796c 100644 --- a/docs/models/virtualization/virtualmachine.md +++ b/docs/models/virtualization/virtualmachine.md @@ -13,6 +13,12 @@ The virtual machine's configured name. Must be unique within its scoping context - If assigned to a **cluster**: unique within the cluster and tenant. - If assigned to a **device** (no cluster): unique within the device and tenant. +### Type + +The [virtual machine type](./virtualmachinetype.md) assigned to the VM. A type classifies a virtual machine and can provide default values for platform, vCPUs, and memory when the VM is created. + +Changes made to a virtual machine type do **not** apply retroactively to existing virtual machines. + ### Role The functional role assigned to the VM. @@ -45,7 +51,7 @@ The location or host for this VM. At least one must be specified: ### Platform -A VM may be associated with a particular [platform](../dcim/platform.md) to indicate its operating system. +A VM may be associated with a particular [platform](../dcim/platform.md) to indicate its operating system. If a virtual machine type defines a default platform, it will be applied when the VM is created unless an explicit platform is specified. ### Primary IPv4 & IPv6 Addresses @@ -56,11 +62,11 @@ Each VM may designate one primary IPv4 address and/or one primary IPv6 address f ### vCPUs -The number of virtual CPUs provisioned. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU). +The number of virtual CPUs provisioned. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU). If a virtual machine type defines a default vCPU allocation, it will be applied when the VM is created unless an explicit value is specified. ### Memory -The amount of running memory provisioned, in megabytes. +The amount of running memory provisioned, in megabytes. If a virtual machine type defines a default memory allocation, it will be applied when the VM is created unless an explicit value is specified. ### Disk diff --git a/docs/models/virtualization/virtualmachinetype.md b/docs/models/virtualization/virtualmachinetype.md new file mode 100644 index 000000000..daf213c56 --- /dev/null +++ b/docs/models/virtualization/virtualmachinetype.md @@ -0,0 +1,27 @@ +# Virtual Machine Types + +A virtual machine type defines a reusable classification and default configuration for [virtual machines](./virtualmachine.md). + +A type can optionally provide default values for a VM's [platform](../dcim/platform.md), vCPU allocation, and memory allocation. When a virtual machine is created with an assigned type, any unset values among these fields will inherit their defaults from the type. Changes made to a virtual machine type do **not** apply retroactively to existing virtual machines. + +## Fields + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) + +### Default Platform + +If defined, virtual machines instantiated with this type will automatically inherit the selected platform when no explicit platform is provided. + +### Default vCPUs + +The default number of vCPUs to assign when creating a virtual machine from this type. + +### Default Memory + +The default amount of memory, in megabytes, to assign when creating a virtual machine from this type. diff --git a/mkdocs.yml b/mkdocs.yml index 3c78965b2..17cc9624e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -285,9 +285,10 @@ nav: - Cluster: 'models/virtualization/cluster.md' - ClusterGroup: 'models/virtualization/clustergroup.md' - ClusterType: 'models/virtualization/clustertype.md' - - VMInterface: 'models/virtualization/vminterface.md' - VirtualDisk: 'models/virtualization/virtualdisk.md' - VirtualMachine: 'models/virtualization/virtualmachine.md' + - VirtualMachineType: 'models/virtualization/virtualmachinetype.md' + - VMInterface: 'models/virtualization/vminterface.md' - VPN: - IKEPolicy: 'models/vpn/ikepolicy.md' - IKEProposal: 'models/vpn/ikeproposal.md' diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 56e7f27f7..d661854d6 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1305,6 +1305,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): 'virtualdevicecontext', 'virtualdisk', 'virtualmachine', + 'virtualmachinetype', 'vlan', 'vlangroup', 'vlantranslationpolicy', diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 399997593..4fa24ecb4 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -270,6 +270,12 @@ VIRTUALIZATION_MENU = Menu( get_model_item('virtualization', 'virtualdisk', _('Virtual Disks')), ), ), + MenuGroup( + label=_('Virtual Machine Types'), + items=( + get_model_item('virtualization', 'virtualmachinetype', _('Virtual Machine Types')), + ), + ), MenuGroup( label=_('Clusters'), items=( diff --git a/netbox/templates/virtualization/virtualmachinetype.html b/netbox/templates/virtualization/virtualmachinetype.html new file mode 100644 index 000000000..86958b930 --- /dev/null +++ b/netbox/templates/virtualization/virtualmachinetype.html @@ -0,0 +1,10 @@ +{% extends 'generic/object.html' %} +{% load i18n %} + +{% block extra_controls %} + {% if perms.virtualization.add_virtualmachine %} + + {% trans "Add Virtual Machine" %} + + {% endif %} +{% endblock extra_controls %} diff --git a/netbox/virtualization/api/serializers_/virtualmachines.py b/netbox/virtualization/api/serializers_/virtualmachines.py index 70355a1bb..ac2621511 100644 --- a/netbox/virtualization/api/serializers_/virtualmachines.py +++ b/netbox/virtualization/api/serializers_/virtualmachines.py @@ -16,10 +16,10 @@ from netbox.api.fields import ChoiceField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer, PrimaryModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer from users.api.serializers_.mixins import OwnerMixin -from virtualization.choices import * -from virtualization.models import VirtualDisk, VirtualMachine, VMInterface from vpn.api.serializers_.l2vpn import L2VPNTerminationSerializer +from ...choices import * +from ...models import VirtualDisk, VirtualMachine, VirtualMachineType, VMInterface from .clusters import ClusterSerializer from .nested import NestedVMInterfaceSerializer @@ -27,11 +27,29 @@ __all__ = ( 'VMInterfaceSerializer', 'VirtualDiskSerializer', 'VirtualMachineSerializer', + 'VirtualMachineTypeSerializer', 'VirtualMachineWithConfigContextSerializer', ) +class VirtualMachineTypeSerializer(PrimaryModelSerializer): + default_platform = PlatformSerializer(nested=True, required=False, allow_null=True) + + # Counter fields + virtual_machine_count = serializers.IntegerField(read_only=True) + + class Meta: + model = VirtualMachineType + fields = [ + 'id', 'url', 'display_url', 'display', 'name', 'slug', 'default_platform', 'default_vcpus', + 'default_memory', 'description', 'owner', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', 'virtual_machine_count', + ] + brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description') + + class VirtualMachineSerializer(PrimaryModelSerializer): + virtual_machine_type = VirtualMachineTypeSerializer(nested=True, required=False, allow_null=True, default=None) status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) start_on_boot = ChoiceField(choices=VirtualMachineStartOnBootChoices, required=False) site = SiteSerializer(nested=True, required=False, allow_null=True, default=None) @@ -52,10 +70,10 @@ class VirtualMachineSerializer(PrimaryModelSerializer): class Meta: model = VirtualMachine fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'status', 'start_on_boot', 'site', 'cluster', 'device', - 'serial', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', - 'disk', 'description', 'owner', 'comments', 'config_template', 'local_context_data', 'tags', - 'custom_fields', 'created', 'last_updated', 'interface_count', 'virtual_disk_count', + 'id', 'url', 'display_url', 'display', 'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', + 'site', 'cluster', 'device', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', + 'disk', 'description', 'serial', 'tenant', 'owner', 'comments', 'tags', 'local_context_data', + 'config_template', 'custom_fields', 'created', 'last_updated', 'interface_count', 'virtual_disk_count', ] brief_fields = ('id', 'url', 'display', 'name', 'description') @@ -65,10 +83,11 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class Meta(VirtualMachineSerializer.Meta): fields = [ - 'id', 'url', 'display_url', 'display', 'name', 'status', 'start_on_boot', 'site', 'cluster', 'device', - 'serial', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', - 'disk', 'description', 'owner', 'comments', 'config_template', 'local_context_data', 'tags', - 'custom_fields', 'config_context', 'created', 'last_updated', 'interface_count', 'virtual_disk_count', + 'id', 'url', 'display_url', 'display', 'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', + 'site', 'cluster', 'device', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', + 'disk', 'description', 'serial', 'tenant', 'owner', 'comments', 'tags', 'local_context_data', + 'config_template', 'custom_fields', 'created', 'last_updated', 'interface_count', 'virtual_disk_count', + 'config_context', ] @extend_schema_field(serializers.JSONField(allow_null=True)) diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py index 013f8b78e..4df0299c9 100644 --- a/netbox/virtualization/api/urls.py +++ b/netbox/virtualization/api/urls.py @@ -10,6 +10,9 @@ router.register('cluster-types', views.ClusterTypeViewSet) router.register('cluster-groups', views.ClusterGroupViewSet) router.register('clusters', views.ClusterViewSet) +# Virtual machine types +router.register('virtual-machine-types', views.VirtualMachineTypeViewSet) + # VirtualMachines router.register('virtual-machines', views.VirtualMachineViewSet) router.register('interfaces', views.VMInterfaceViewSet) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 4fe0e9ca7..d1f06762a 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -14,6 +14,7 @@ class VirtualizationRootView(APIRootView): """ Virtualization API root view """ + def get_view_name(self): return 'Virtualization' @@ -22,6 +23,7 @@ class VirtualizationRootView(APIRootView): # Clusters # + class ClusterTypeViewSet(NetBoxModelViewSet): queryset = ClusterType.objects.all() serializer_class = serializers.ClusterTypeSerializer @@ -44,10 +46,22 @@ class ClusterViewSet(NetBoxModelViewSet): filterset_class = filtersets.ClusterFilterSet +# +# Virtual machine types +# + + +class VirtualMachineTypeViewSet(NetBoxModelViewSet): + queryset = VirtualMachineType.objects.all() + serializer_class = serializers.VirtualMachineTypeSerializer + filterset_class = filtersets.VirtualMachineTypeFilterSet + + # # Virtual machines # + class VirtualMachineViewSet(ConfigContextQuerySetMixin, RenderConfigMixin, NetBoxModelViewSet): queryset = VirtualMachine.objects.all() filterset_class = filtersets.VirtualMachineFilterSet diff --git a/netbox/virtualization/apps.py b/netbox/virtualization/apps.py index d337e0219..81811aca9 100644 --- a/netbox/virtualization/apps.py +++ b/netbox/virtualization/apps.py @@ -9,10 +9,10 @@ class VirtualizationConfig(AppConfig): from utilities.counters import connect_counters from . import search, signals # noqa: F401 - from .models import VirtualMachine + from .models import VirtualMachine, VirtualMachineType # Register models register_models(*self.get_models()) # Register counters - connect_counters(VirtualMachine) + connect_counters(VirtualMachine, VirtualMachineType) diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 56449f641..ea12eccd1 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -26,6 +26,7 @@ __all__ = ( 'VMInterfaceFilterSet', 'VirtualDiskFilterSet', 'VirtualMachineFilterSet', + 'VirtualMachineTypeFilterSet', ) @@ -91,6 +92,45 @@ class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ScopedFilterSet, ) +@register_filterset +class VirtualMachineTypeFilterSet(PrimaryModelFilterSet): + default_platform_id = TreeNodeMultipleChoiceFilter( + queryset=Platform.objects.all(), + field_name='default_platform', + lookup_expr='in', + label=_('Default platform (ID)'), + ) + default_platform = TreeNodeMultipleChoiceFilter( + queryset=Platform.objects.all(), + field_name='default_platform', + to_field_name='slug', + lookup_expr='in', + label=_('Default platform (slug)'), + ) + + class Meta: + model = VirtualMachineType + fields = ( + 'id', + 'name', + 'slug', + 'default_vcpus', + 'default_memory', + 'description', + 'virtual_machine_count', + ) + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) | + Q(comments__icontains=value) + ) + + @register_filterset class VirtualMachineFilterSet( PrimaryModelFilterSet, @@ -99,6 +139,18 @@ class VirtualMachineFilterSet( LocalConfigContextFilterSet, PrimaryIPFilterSet, ): + virtual_machine_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=VirtualMachineType.objects.all(), + distinct=False, + label=_('Virtual machine type (ID)'), + ) + virtual_machine_type = django_filters.ModelMultipleChoiceFilter( + field_name='virtual_machine_type__slug', + queryset=VirtualMachineType.objects.all(), + distinct=False, + to_field_name='slug', + label=_('Virtual machine type (slug)'), + ) status = django_filters.MultipleChoiceFilter( choices=VirtualMachineStatusChoices, distinct=False, diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index beab54093..5b7ca78c7 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -14,8 +14,9 @@ from utilities.forms import BulkRenameForm, add_blank_choice from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet from utilities.forms.widgets import BulkEditNullBooleanSelect -from virtualization.choices import * -from virtualization.models import * + +from ..choices import * +from ..models import * __all__ = ( 'ClusterBulkEditForm', @@ -26,6 +27,7 @@ __all__ = ( 'VirtualDiskBulkEditForm', 'VirtualDiskBulkRenameForm', 'VirtualMachineBulkEditForm', + 'VirtualMachineTypeBulkEditForm', ) @@ -78,7 +80,37 @@ class ClusterBulkEditForm(ScopedBulkEditForm, PrimaryModelBulkEditForm): ) +class VirtualMachineTypeBulkEditForm(PrimaryModelBulkEditForm): + default_platform = DynamicModelChoiceField( + label=_('Default platform'), + queryset=Platform.objects.all(), + required=False + ) + default_vcpus = forms.IntegerField( + label=_('Default vCPUs'), + required=False, + ) + default_memory = forms.IntegerField( + label=_('Default Memory (MB)'), + required=False, + ) + + model = VirtualMachineType + fieldsets = ( + FieldSet('description', name=_('Virtual Machine Type')), + FieldSet('default_platform', 'default_vcpus', 'default_memory', name=_('Defaults')), + ) + nullable_fields = ( + 'default_platform', 'default_vcpus', 'default_memory', 'description', 'comments', + ) + + class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm): + virtual_machine_type = DynamicModelChoiceField( + label=_('Virtual machine type'), + queryset=VirtualMachineType.objects.all(), + required=False + ) status = forms.ChoiceField( label=_('Status'), choices=add_blank_choice(VirtualMachineStatusChoices), @@ -152,13 +184,14 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm): model = VirtualMachine fieldsets = ( - FieldSet('status', 'start_on_boot', 'role', 'tenant', 'platform', 'description'), + FieldSet('virtual_machine_type', 'status', 'start_on_boot', 'role', 'tenant', 'platform', 'description'), FieldSet('site', 'cluster', 'device', name=_('Placement')), FieldSet('vcpus', 'memory', 'disk', name=_('Resources')), FieldSet('config_template', name=_('Configuration')), ) nullable_fields = ( - 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'description', 'comments', + 'virtual_machine_type', 'role', 'site', 'cluster', 'device', 'platform', 'vcpus', 'memory', 'disk', 'tenant', + 'description', 'comments', ) diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 0e0b0ab88..14061dd8a 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -10,8 +10,9 @@ from ipam.models import VLAN, VRF, VLANGroup from netbox.forms import NetBoxModelImportForm, OrganizationalModelImportForm, OwnerCSVMixin, PrimaryModelImportForm from tenancy.models import Tenant from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField -from virtualization.choices import * -from virtualization.models import * + +from ..choices import * +from ..models import * __all__ = ( 'ClusterGroupImportForm', @@ -20,6 +21,7 @@ __all__ = ( 'VMInterfaceImportForm', 'VirtualDiskImportForm', 'VirtualMachineImportForm', + 'VirtualMachineTypeImportForm', ) @@ -82,7 +84,31 @@ class ClusterImportForm(ScopedImportForm, PrimaryModelImportForm): } +class VirtualMachineTypeImportForm(PrimaryModelImportForm): + default_platform = CSVModelChoiceField( + label=_('Default platform'), + queryset=Platform.objects.all(), + required=False, + to_field_name='name', + help_text=_('Assigned default platform'), + ) + + class Meta: + model = VirtualMachineType + fields = ( + 'name', 'slug', 'default_platform', 'default_vcpus', 'default_memory', 'description', + 'owner', 'comments', 'tags', + ) + + class VirtualMachineImportForm(PrimaryModelImportForm): + virtual_machine_type = CSVModelChoiceField( + label=_('Virtual machine type'), + queryset=VirtualMachineType.objects.all(), + to_field_name='name', + required=False, + help_text=_('Optional virtual machine type'), + ) status = CSVChoiceField( label=_('Status'), choices=VirtualMachineStatusChoices, @@ -149,8 +175,9 @@ class VirtualMachineImportForm(PrimaryModelImportForm): class Meta: model = VirtualMachine fields = ( - 'name', 'status', 'start_on_boot', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', - 'memory', 'disk', 'description', 'serial', 'config_template', 'comments', 'owner', 'tags', + 'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', 'site', 'cluster', 'device', + 'platform', 'vcpus', 'memory', 'disk', 'description', 'serial', + 'tenant', 'owner', 'comments', 'tags', 'config_template', ) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 5b1b44cb6..4ba14080a 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -12,10 +12,11 @@ from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES from utilities.forms.fields import DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.rendering import FieldSet -from virtualization.choices import * -from virtualization.models import * from vpn.models import L2VPN +from ..choices import * +from ..models import * + __all__ = ( 'ClusterFilterForm', 'ClusterGroupFilterForm', @@ -23,6 +24,7 @@ __all__ = ( 'VMInterfaceFilterForm', 'VirtualDiskFilterForm', 'VirtualMachineFilterForm', + 'VirtualMachineTypeFilterForm', ) @@ -100,6 +102,43 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelF tag = TagFilterField(model) +class VirtualMachineTypeFilterForm(PrimaryModelFilterSetForm): + model = VirtualMachineType + + fieldsets = ( + FieldSet('q', 'filter_id', 'tag'), + FieldSet( + 'default_platform_id', 'default_vcpus', 'default_memory', 'virtual_machine_count', + name=_('Attributes'), + ), + FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), + ) + + selector_fields = ('filter_id', 'q') + + default_platform_id = DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), + required=False, + label=_('Default platform'), + ) + default_vcpus = forms.DecimalField( + label=_('Default vCPUs'), + required=False, + ) + default_memory = forms.IntegerField( + label=_('Default memory (MB)'), + required=False, + min_value=0, + ) + virtual_machine_count = forms.IntegerField( + label=_('Virtual machine count'), + required=False, + min_value=0, + ) + + tag = TagFilterField(model) + + class VirtualMachineFilterForm( LocalConfigContextFilterForm, TenancyFilterForm, @@ -112,13 +151,20 @@ class VirtualMachineFilterForm( FieldSet('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id', name=_('Cluster')), FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), FieldSet( - 'status', 'start_on_boot', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'config_template_id', - 'local_context_data', 'serial', name=_('Attributes') + 'virtual_machine_type_id', 'status', 'start_on_boot', 'role_id', 'platform_id', 'mac_address', + 'has_primary_ip', 'config_template_id', 'local_context_data', 'serial', + name=_('Attributes') ), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('owner_group_id', 'owner_id', name=_('Ownership')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) + virtual_machine_type_id = DynamicModelMultipleChoiceField( + queryset=VirtualMachineType.objects.all(), + required=False, + null_option='None', + label=_('Virtual machine type'), + ) cluster_group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), required=False, diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 28e59bdd1..869f4f5b7 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -14,10 +14,11 @@ from netbox.forms import NetBoxModelForm, OrganizationalModelForm, PrimaryModelF from netbox.forms.mixins import OwnerMixin from tenancy.forms import TenancyForm from utilities.forms import ConfirmationForm -from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField +from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField from utilities.forms.rendering import FieldSet from utilities.forms.widgets import HTMXSelect -from virtualization.models import * + +from ..models import * __all__ = ( 'ClusterAddDevicesForm', @@ -28,6 +29,7 @@ __all__ = ( 'VMInterfaceForm', 'VirtualDiskForm', 'VirtualMachineForm', + 'VirtualMachineTypeForm', ) @@ -167,7 +169,35 @@ class ClusterRemoveDevicesForm(ConfirmationForm): ) +class VirtualMachineTypeForm(PrimaryModelForm): + slug = SlugField() + default_platform = DynamicModelChoiceField( + label=_('Default platform'), + queryset=Platform.objects.all(), + required=False, + selector=True, + ) + + fieldsets = ( + FieldSet('name', 'slug', 'description', 'tags', name=_('Virtual Machine Type')), + FieldSet('default_platform', 'default_vcpus', 'default_memory', name=_('Defaults')), + ) + + class Meta: + model = VirtualMachineType + fields = ( + 'name', 'slug', 'default_platform', 'default_vcpus', 'default_memory', 'description', + 'owner', 'comments', 'tags', + ) + + class VirtualMachineForm(TenancyForm, PrimaryModelForm): + virtual_machine_type = DynamicModelChoiceField( + label=_('Type'), + queryset=VirtualMachineType.objects.all(), + required=False, + selector=True, + ) site = DynamicModelChoiceField( label=_('Site'), queryset=Site.objects.all(), @@ -226,7 +256,10 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm): ) fieldsets = ( - FieldSet('name', 'role', 'status', 'start_on_boot', 'description', 'serial', 'tags', name=_('Virtual Machine')), + FieldSet( + 'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', 'description', 'serial', 'tags', + name=_('Virtual Machine') + ), FieldSet('site', 'cluster', 'device', name=_('Placement')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), FieldSet('platform', 'primary_ip4', 'primary_ip6', 'config_template', name=_('Management')), @@ -237,9 +270,9 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm): class Meta: model = VirtualMachine fields = [ - 'name', 'status', 'start_on_boot', 'site', 'cluster', 'device', 'role', 'tenant_group', 'tenant', - 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'serial', 'owner', - 'comments', 'tags', 'local_context_data', 'config_template', + 'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', 'site', 'cluster', 'device', + 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'serial', + 'tenant_group', 'tenant', 'owner', 'comments', 'tags', 'local_context_data', 'config_template', ] def __init__(self, *args, **kwargs): diff --git a/netbox/virtualization/graphql/filters.py b/netbox/virtualization/graphql/filters.py index eb750cec8..f8574eb6b 100644 --- a/netbox/virtualization/graphql/filters.py +++ b/netbox/virtualization/graphql/filters.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Annotated import strawberry import strawberry_django from strawberry.scalars import ID -from strawberry_django import BaseFilterLookup, FilterLookup, StrFilterLookup +from strawberry_django import BaseFilterLookup, ComparisonFilterLookup, StrFilterLookup from dcim.graphql.filter_mixins import InterfaceBaseFilterMixin, RenderConfigFilterMixin, ScopedFilterMixin from extras.graphql.filter_mixins import ConfigContextFilterMixin @@ -34,6 +34,7 @@ __all__ = ( 'VMInterfaceFilter', 'VirtualDiskFilter', 'VirtualMachineFilter', + 'VirtualMachineTypeFilter', ) @@ -68,6 +69,24 @@ class ClusterTypeFilter(OrganizationalModelFilter): pass +@strawberry_django.filter_type(models.VirtualMachineType, lookups=True) +class VirtualMachineTypeFilter(ImageAttachmentFilterMixin, PrimaryModelFilter): + default_platform: Annotated['PlatformFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + default_platform_id: ID | None = strawberry_django.filter_field() + default_vcpus: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + default_memory: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = ( + strawberry_django.filter_field() + ) + instances: Annotated['VirtualMachineFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( + strawberry_django.filter_field() + ) + virtual_machine_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field() + + @strawberry_django.filter_type(models.VirtualMachine, lookups=True) class VirtualMachineFilter( ContactFilterMixin, @@ -78,6 +97,9 @@ class VirtualMachineFilter( PrimaryModelFilter, ): name: StrFilterLookup[str] | None = strawberry_django.filter_field() + virtual_machine_type: ( + Annotated['VirtualMachineTypeFilter', strawberry.lazy('virtualization.graphql.filters')] | None + ) = strawberry_django.filter_field() site: Annotated['SiteFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field() site_id: ID | None = strawberry_django.filter_field() cluster: Annotated['ClusterFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( @@ -92,9 +114,7 @@ class VirtualMachineFilter( platform_id: ID | None = strawberry_django.filter_field() status: ( BaseFilterLookup[Annotated['VirtualMachineStatusEnum', strawberry.lazy('virtualization.graphql.enums')]] | None - ) = ( - strawberry_django.filter_field() - ) + ) = strawberry_django.filter_field() role: Annotated['DeviceRoleFilter', strawberry.lazy('dcim.graphql.filters')] | None = ( strawberry_django.filter_field() ) @@ -117,8 +137,6 @@ class VirtualMachineFilter( strawberry_django.filter_field() ) serial: StrFilterLookup[str] | None = strawberry_django.filter_field() - interface_count: FilterLookup[int] | None = strawberry_django.filter_field() - virtual_disk_count: FilterLookup[int] | None = strawberry_django.filter_field() interfaces: Annotated['VMInterfaceFilter', strawberry.lazy('virtualization.graphql.filters')] | None = ( strawberry_django.filter_field() ) @@ -129,10 +147,11 @@ class VirtualMachineFilter( strawberry_django.filter_field() ) start_on_boot: ( - BaseFilterLookup[Annotated['VirtualMachineStartOnBootEnum', strawberry.lazy('virtualization.graphql.enums')] - ] | None) = ( - strawberry_django.filter_field() - ) + BaseFilterLookup[Annotated['VirtualMachineStartOnBootEnum', strawberry.lazy('virtualization.graphql.enums')]] + | None + ) = strawberry_django.filter_field() + interface_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field() + virtual_disk_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field() @strawberry_django.filter_type(models.VMInterface, lookups=True) diff --git a/netbox/virtualization/graphql/schema.py b/netbox/virtualization/graphql/schema.py index f0dee3815..7ea7a2b49 100644 --- a/netbox/virtualization/graphql/schema.py +++ b/netbox/virtualization/graphql/schema.py @@ -4,7 +4,7 @@ import strawberry_django from .types import * -@strawberry.type(name="Query") +@strawberry.type(name='Query') class VirtualizationQuery: cluster: ClusterType = strawberry_django.field() cluster_list: list[ClusterType] = strawberry_django.field() @@ -15,6 +15,9 @@ class VirtualizationQuery: cluster_type: ClusterTypeType = strawberry_django.field() cluster_type_list: list[ClusterTypeType] = strawberry_django.field() + virtual_machine_type: VirtualMachineTypeType = strawberry_django.field() + virtual_machine_type_list: list[VirtualMachineTypeType] = strawberry_django.field() + virtual_machine: VirtualMachineType = strawberry_django.field() virtual_machine_list: list[VirtualMachineType] = strawberry_django.field() diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index 68a6e19ac..96563aac8 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -34,6 +34,7 @@ __all__ = ( 'VMInterfaceType', 'VirtualDiskType', 'VirtualMachineType', + 'VirtualMachineTypeType', ) @@ -91,6 +92,19 @@ class ClusterTypeType(OrganizationalObjectType): clusters: list[ClusterType] +@strawberry_django.type( + models.VirtualMachineType, + fields='__all__', + filters=VirtualMachineTypeFilter, + pagination=True +) +class VirtualMachineTypeType(PrimaryObjectType): + virtual_machine_count: BigInt + default_platform: Annotated['PlatformType', strawberry.lazy('dcim.graphql.types')] | None + + instances: list[Annotated['VirtualMachineType', strawberry.lazy('virtualization.graphql.types')]] + + @strawberry_django.type( models.VirtualMachine, fields='__all__', @@ -101,6 +115,7 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, PrimaryObjectType): interface_count: BigInt virtual_disk_count: BigInt interface_count: BigInt + virtual_machine_type: Annotated['VirtualMachineTypeType', strawberry.lazy('virtualization.graphql.types')] | None config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None cluster: Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')] | None diff --git a/netbox/virtualization/migrations/0054_virtualmachinetype.py b/netbox/virtualization/migrations/0054_virtualmachinetype.py new file mode 100644 index 000000000..1a68b6229 --- /dev/null +++ b/netbox/virtualization/migrations/0054_virtualmachinetype.py @@ -0,0 +1,106 @@ +from decimal import Decimal + +import django.core.validators +import django.db.models.deletion +import django.db.models.functions.text +import taggit.managers +from django.db import migrations, models + +import netbox.models.deletion +import utilities.fields +import utilities.json + + +class Migration(migrations.Migration): + dependencies = [ + ('virtualization', '0053_virtualmachine_standalone_device_assignment'), + ] + + operations = [ + migrations.CreateModel( + name='VirtualMachineType', + 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), + ), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('name', models.CharField(max_length=100)), + ('slug', models.SlugField(max_length=100)), + ( + 'default_vcpus', + models.DecimalField( + blank=True, + decimal_places=2, + max_digits=6, + null=True, + validators=[django.core.validators.MinValueValidator(Decimal('0.01'))], + ), + ), + ('default_memory', models.PositiveIntegerField(blank=True, null=True)), + ( + 'virtual_machine_count', + utilities.fields.CounterCacheField( + default=0, + editable=False, + to_field='virtual_machine_type', + to_model='virtualization.VirtualMachine', + ), + ), + ( + 'default_platform', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='dcim.platform', + ), + ), + ( + 'owner', + models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='users.owner' + ), + ), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'virtual machine type', + 'verbose_name_plural': 'virtual machine types', + 'ordering': ('name',), + }, + bases=(netbox.models.deletion.DeleteMixin, models.Model), + ), + migrations.AddField( + model_name='virtualmachine', + name='virtual_machine_type', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='instances', + to='virtualization.virtualmachinetype', + ), + ), + migrations.AddConstraint( + model_name='virtualmachinetype', + constraint=models.UniqueConstraint( + django.db.models.functions.text.Lower('name'), + name='virtualization_virtualmachinetype_unique_name', + violation_error_message='Virtual machine type name must be unique.', + ), + ), + migrations.AddConstraint( + model_name='virtualmachinetype', + constraint=models.UniqueConstraint( + fields=('slug',), + name='virtualization_virtualmachinetype_unique_slug', + violation_error_message='Virtual machine type slug must be unique.', + ), + ), + ] diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index b8b82ffc7..8314c8904 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -20,16 +20,88 @@ from utilities.fields import CounterCacheField, NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar from utilities.tracking import TrackingModelMixin -from virtualization.choices import * + +from ..choices import * __all__ = ( 'VMInterface', 'VirtualDisk', 'VirtualMachine', + 'VirtualMachineType', ) -class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, ConfigContextModel, PrimaryModel): +class VirtualMachineType(ImageAttachmentsMixin, PrimaryModel): + """ + A type defining default attributes (platform, vCPUs, memory, etc.) for virtual machines. + """ + + name = models.CharField( + verbose_name=_('name'), + max_length=100, + ) + slug = models.SlugField( + verbose_name=_('slug'), + max_length=100, + ) + default_platform = models.ForeignKey( + to='dcim.Platform', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + verbose_name=_('default platform'), + ) + default_vcpus = models.DecimalField( + verbose_name=_('default vCPUs'), + max_digits=6, + decimal_places=2, + blank=True, + null=True, + validators=(MinValueValidator(decimal.Decimal('0.01')),), + ) + default_memory = models.PositiveIntegerField( + verbose_name=_('default memory (MB)'), + blank=True, + null=True, + ) + + # Counter fields + virtual_machine_count = CounterCacheField( + to_model='virtualization.VirtualMachine', + to_field='virtual_machine_type', + ) + + clone_fields = ( + 'default_platform', + 'default_vcpus', + 'default_memory', + ) + + class Meta: + ordering = ('name',) + constraints = ( + models.UniqueConstraint( + Lower('name'), + name='%(app_label)s_%(class)s_unique_name', + violation_error_message=_('Virtual machine type name must be unique.'), + ), + models.UniqueConstraint( + fields=('slug',), + name='%(app_label)s_%(class)s_unique_slug', + violation_error_message=_('Virtual machine type slug must be unique.'), + ), + ) + verbose_name = _('virtual machine type') + verbose_name_plural = _('virtual machine types') + + def __str__(self): + return self.name + + +class VirtualMachine( + ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, ConfigContextModel, TrackingModelMixin, PrimaryModel +): """ A virtual machine which runs on a Cluster or a standalone Device. @@ -42,6 +114,15 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co When a Cluster or Device is set, the Site is automatically inherited if not explicitly provided. If a Device belongs to a Cluster, the Cluster must also be specified on the VM. """ + + virtual_machine_type = models.ForeignKey( + to='virtualization.VirtualMachineType', + on_delete=models.PROTECT, + related_name='instances', + verbose_name=_('type'), + blank=True, + null=True, + ) site = models.ForeignKey( to='dcim.Site', on_delete=models.PROTECT, @@ -162,7 +243,8 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co objects = ConfigContextModelQuerySet.as_manager() clone_fields = ( - 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk', + 'virtual_machine_type', 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', + 'disk', ) class Meta: @@ -282,8 +364,29 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co elif self.device and self.device.site: self.site = self.device.site + if self._state.adding: + self.apply_type_defaults() + super().save(*args, **kwargs) + def apply_type_defaults(self): + """ + Populate any empty fields with defaults from the assigned VirtualMachineType. + """ + if not self.virtual_machine_type_id: + return + + defaults = { + 'platform_id': 'default_platform_id', + 'vcpus': 'default_vcpus', + 'memory': 'default_memory', + } + for field, default_field in defaults.items(): + if getattr(self, field) is None: + default_value = getattr(self.virtual_machine_type, default_field) + if default_value is not None: + setattr(self, field, default_value) + def get_status_color(self): return VirtualMachineStatusChoices.colors.get(self.status) diff --git a/netbox/virtualization/search.py b/netbox/virtualization/search.py index 65f4928b5..e75ea4899 100644 --- a/netbox/virtualization/search.py +++ b/netbox/virtualization/search.py @@ -38,6 +38,18 @@ class ClusterTypeIndex(SearchIndex): display_attrs = ('description',) +@register_search +class VirtualMachineTypeIndex(SearchIndex): + model = models.VirtualMachineType + fields = ( + ('name', 100), + ('slug', 110), + ('description', 500), + ('comments', 5000), + ) + display_attrs = ('default_platform', 'default_vcpus', 'default_memory', 'description') + + @register_search class VirtualMachineIndex(SearchIndex): model = models.VirtualMachine diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index d218392c4..298ad6277 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -5,28 +5,64 @@ from dcim.tables.devices import BaseInterfaceTable from netbox.tables import NetBoxTable, PrimaryModelTable, columns from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin from utilities.templatetags.helpers import humanize_disk_megabytes -from virtualization.models import VirtualDisk, VirtualMachine, VMInterface +from ..models import VirtualDisk, VirtualMachine, VirtualMachineType, VMInterface from .template_code import * __all__ = ( 'VMInterfaceTable', 'VirtualDiskTable', 'VirtualMachineTable', + 'VirtualMachineTypeTable', 'VirtualMachineVMInterfaceTable', 'VirtualMachineVirtualDiskTable', ) +# +# Virtual machine types +# + + +class VirtualMachineTypeTable(PrimaryModelTable): + name = tables.Column( + verbose_name=_('Name'), + linkify=True, + ) + default_platform = tables.Column( + verbose_name=_('Default platform'), + linkify=True, + ) + virtual_machine_count = tables.Column( + verbose_name=_('VM count') + ) + tags = columns.TagColumn( + url_name='virtualization:virtualmachinetype_list' + ) + + class Meta(PrimaryModelTable.Meta): + model = VirtualMachineType + fields = ( + 'pk', 'id', 'name', 'slug', 'default_platform', 'default_vcpus', 'default_memory', 'virtual_machine_count', + 'description', 'comments', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'default_platform', 'default_vcpus', 'default_memory', 'virtual_machine_count', 'description', + ) # # Virtual machines # + class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModelTable): name = tables.Column( verbose_name=_('Name'), linkify=True ) + virtual_machine_type = tables.Column( + verbose_name=_('Type'), + linkify=True, + ) status = columns.ChoiceFieldColumn( verbose_name=_('Status'), ) @@ -85,12 +121,13 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModel class Meta(PrimaryModelTable.Meta): model = VirtualMachine fields = ( - 'pk', 'id', 'name', 'status', 'start_on_boot', 'site', 'cluster', 'device', 'role', 'tenant', - 'tenant_group', 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', - 'comments', 'config_template', 'serial', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', 'site', 'cluster', 'device', + 'platform', 'primary_ip4', 'primary_ip6', 'primary_ip', 'vcpus', 'memory', 'disk', 'description', 'serial', + 'tenant_group', 'tenant', 'contacts', 'comments', 'tags', 'config_template', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', + 'pk', 'name', 'virtual_machine_type', 'role', 'status', 'site', 'cluster', 'tenant', + 'vcpus', 'memory', 'disk', 'primary_ip', ) def render_disk(self, value): diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 453298025..b53337b0e 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -7,7 +7,7 @@ from rest_framework import status from core.models import ObjectType from dcim.choices import InterfaceModeChoices -from dcim.models import Site +from dcim.models import Platform, Site from extras.choices import CustomFieldTypeChoices from extras.models import ConfigTemplate, CustomField from ipam.choices import VLANQinQRoleChoices @@ -167,54 +167,170 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): ] +class VirtualMachineTypeTest(APIViewTestCases.APIViewTestCase): + model = VirtualMachineType + brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url'] + user_permissions = ('dcim.view_platform', 'virtualization.view_virtualmachine') + + @classmethod + def setUpTestData(cls): + cls.platforms = ( + Platform.objects.create(name='Platform 1', slug='platform-1'), + Platform.objects.create(name='Platform 2', slug='platform-2'), + Platform.objects.create(name='Platform 3', slug='platform-3'), + ) + + cls.virtual_machine_types = ( + VirtualMachineType.objects.create( + name='Virtual Machine Type 1', + slug='virtual-machine-type-1', + default_platform=cls.platforms[0], + default_vcpus=1, + default_memory=1024, + ), + VirtualMachineType.objects.create( + name='Virtual Machine Type 2', + slug='virtual-machine-type-2', + default_platform=cls.platforms[1], + default_vcpus=2, + default_memory=2048, + ), + VirtualMachineType.objects.create( + name='Virtual Machine Type 3', + slug='virtual-machine-type-3', + default_platform=cls.platforms[2], + default_vcpus=4, + default_memory=4096, + ), + ) + + cls.create_data = [ + { + 'name': 'Virtual Machine Type 4', + 'slug': 'virtual-machine-type-4', + 'default_platform': cls.platforms[0].pk, + 'default_vcpus': 1, + 'default_memory': 1024, + }, + { + 'name': 'Virtual Machine Type 5', + 'slug': 'virtual-machine-type-5', + 'default_platform': cls.platforms[1].pk, + 'default_vcpus': 2, + 'default_memory': 2048, + }, + { + 'name': 'Virtual Machine Type 6', + 'slug': 'virtual-machine-type-6', + 'default_platform': cls.platforms[2].pk, + 'default_vcpus': 4, + 'default_memory': 4096, + }, + ] + + cls.bulk_update_data = { + 'default_platform': cls.platforms[2].pk, + 'default_vcpus': 8, + 'default_memory': 8192, + 'description': 'New description', + } + + def test_filter_by_default_platform(self): + self.add_permissions('virtualization.view_virtualmachinetype') + + response = self.client.get( + f'{self._get_list_url()}?default_platform_id={self.platforms[0].pk}', + **self.header, + ) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + + response = self.client.get( + f'{self._get_list_url()}?default_platform={self.platforms[0].slug}', + **self.header, + ) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + + class VirtualMachineTest(APIViewTestCases.APIViewTestCase): model = VirtualMachine brief_fields = ['description', 'display', 'id', 'name', 'url'] bulk_update_data = { 'status': 'staged', } + user_permissions = ('dcim.view_platform', 'virtualization.view_virtualmachinetype') @classmethod def setUpTestData(cls): clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1') - sites = ( + cls.sites = ( Site(name='Site 1', slug='site-1'), Site(name='Site 2', slug='site-2'), Site(name='Site 3', slug='site-3'), ) - Site.objects.bulk_create(sites) + Site.objects.bulk_create(cls.sites) - clusters = ( - Cluster(name='Cluster 1', type=clustertype, scope=sites[0], group=clustergroup), - Cluster(name='Cluster 2', type=clustertype, scope=sites[1], group=clustergroup), + cls.clusters = ( + Cluster(name='Cluster 1', type=clustertype, scope=cls.sites[0], group=clustergroup), + Cluster(name='Cluster 2', type=clustertype, scope=cls.sites[1], group=clustergroup), Cluster(name='Cluster 3', type=clustertype), ) - for cluster in clusters: + for cluster in cls.clusters: cluster.save() - device1 = create_test_device('device1', site=sites[0], cluster=clusters[0]) - device2 = create_test_device('device2', site=sites[1], cluster=clusters[1]) + cls.devices = ( + create_test_device('device1', site=cls.sites[0], cluster=cls.clusters[0]), + create_test_device('device2', site=cls.sites[1], cluster=cls.clusters[1]), + ) + + cls.platforms = ( + Platform.objects.create(name='Platform 1', slug='platform-1'), + Platform.objects.create(name='Platform 2', slug='platform-2'), + Platform.objects.create(name='Platform 3', slug='platform-3'), + ) + + cls.vm_types = ( + VirtualMachineType.objects.create( + name='Virtual Machine Type 1', + slug='virtual-machine-type-1', + default_platform=cls.platforms[0], + default_vcpus=2, + default_memory=4096, + ), + VirtualMachineType.objects.create( + name='Virtual Machine Type 2', + slug='virtual-machine-type-2', + default_platform=cls.platforms[1], + default_vcpus=4, + default_memory=8192, + ), + ) virtual_machines = ( VirtualMachine( name='Virtual Machine 1', - site=sites[0], - cluster=clusters[0], - device=device1, + virtual_machine_type=cls.vm_types[0], + site=cls.sites[0], + cluster=cls.clusters[0], + device=cls.devices[0], + platform=cls.platforms[0], + vcpus=2, + memory=4096, local_context_data={'A': 1}, ), VirtualMachine( name='Virtual Machine 2', - site=sites[0], - cluster=clusters[0], - local_context_data={'B': 2 - }), + site=cls.sites[0], + cluster=cls.clusters[0], + local_context_data={'B': 2}, + ), VirtualMachine( name='Virtual Machine 3', - site=sites[0], - cluster=clusters[0], + site=cls.sites[0], + cluster=cls.clusters[0], local_context_data={'C': 3}, start_on_boot=VirtualMachineStartOnBootChoices.STATUS_ON, ), @@ -224,26 +340,106 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): cls.create_data = [ { 'name': 'Virtual Machine 4', - 'site': sites[1].pk, - 'cluster': clusters[1].pk, - 'device': device2.pk, + 'site': cls.sites[1].pk, + 'cluster': cls.clusters[1].pk, + 'device': cls.devices[1].pk, + 'virtual_machine_type': cls.vm_types[0].pk, }, { 'name': 'Virtual Machine 5', - 'site': sites[1].pk, - 'cluster': clusters[1].pk, + 'site': cls.sites[1].pk, + 'cluster': cls.clusters[1].pk, + 'virtual_machine_type': cls.vm_types[1].pk, }, { 'name': 'Virtual Machine 6', - 'site': sites[1].pk, + 'site': cls.sites[1].pk, }, { 'name': 'Virtual Machine 7', - 'cluster': clusters[2].pk, + 'cluster': cls.clusters[2].pk, + 'virtual_machine_type': cls.vm_types[0].pk, 'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_ON, }, ] + def test_filter_by_virtual_machine_type(self): + self.add_permissions('virtualization.view_virtualmachine') + + response = self.client.get( + f'{self._get_list_url()}?virtual_machine_type_id={self.vm_types[0].pk}', + **self.header, + ) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + + response = self.client.get( + f'{self._get_list_url()}?virtual_machine_type={self.vm_types[0].slug}', + **self.header, + ) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 1) + + def test_virtual_machine_type_defaults_applied_on_create(self): + data = { + 'name': 'Virtual Machine With Defaults', + 'site': self.sites[1].pk, + 'cluster': self.clusters[1].pk, + 'virtual_machine_type': self.vm_types[0].pk, + 'platform': None, + 'vcpus': None, + 'memory': None, + } + self.add_permissions('virtualization.add_virtualmachine') + + response = self.client.post(self._get_list_url(), data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + + vm = VirtualMachine.objects.get(pk=response.data['id']) + self.assertEqual(vm.virtual_machine_type, self.vm_types[0]) + self.assertEqual(vm.platform, self.vm_types[0].default_platform) + self.assertEqual(vm.vcpus, self.vm_types[0].default_vcpus) + self.assertEqual(vm.memory, self.vm_types[0].default_memory) + + def test_virtual_machine_type_defaults_do_not_override_explicit_values(self): + data = { + 'name': 'Virtual Machine With Explicit Values', + 'site': self.sites[1].pk, + 'cluster': self.clusters[1].pk, + 'virtual_machine_type': self.vm_types[0].pk, + 'platform': self.platforms[2].pk, + 'vcpus': 6, + 'memory': 12288, + } + self.add_permissions('virtualization.add_virtualmachine') + + response = self.client.post(self._get_list_url(), data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + + vm = VirtualMachine.objects.get(pk=response.data['id']) + self.assertEqual(vm.virtual_machine_type, self.vm_types[0]) + self.assertEqual(vm.platform, self.platforms[2]) + self.assertEqual(vm.vcpus, 6) + self.assertEqual(vm.memory, 12288) + + def test_setting_virtual_machine_type_on_existing_vm_does_not_backfill_defaults(self): + vm = VirtualMachine.objects.get(name='Virtual Machine 2') + self.add_permissions('virtualization.change_virtualmachine') + + response = self.client.patch( + self._get_detail_url(vm), + {'virtual_machine_type': self.vm_types[1].pk}, + format='json', + **self.header, + ) + self.assertHttpStatus(response, status.HTTP_200_OK) + + vm.refresh_from_db() + self.assertEqual(vm.virtual_machine_type, self.vm_types[1]) + self.assertIsNone(vm.platform) + self.assertIsNone(vm.vcpus) + self.assertIsNone(vm.memory) + def test_config_context_included_by_default_in_list_view(self): """ Check that config context data is included by default in the virtual machines list. diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index d9e96d940..dae270eff 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -230,6 +230,116 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) +class VirtualMachineTypeTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = VirtualMachineType.objects.all() + filterset = VirtualMachineTypeFilterSet + + @classmethod + def setUpTestData(cls): + + platforms = ( + Platform(name='Platform 1', slug='platform-1'), + Platform(name='Platform 2', slug='platform-2'), + Platform(name='Platform 3', slug='platform-3'), + ) + for platform in platforms: + platform.save() + + cluster_type = ClusterType.objects.create( + name='Cluster Type 1', + slug='cluster-type-1', + ) + site = Site.objects.create( + name='Site 1', + slug='site-1', + ) + cluster = Cluster.objects.create( + name='Cluster 1', + type=cluster_type, + scope=site, + ) + + cls.vm_types = ( + VirtualMachineType.objects.create( + name='Virtual Machine Type 1', + slug='virtual-machine-type-1', + default_platform=platforms[0], + default_vcpus=1, + default_memory=1024, + description='foobar1', + ), + VirtualMachineType.objects.create( + name='Virtual Machine Type 2', + slug='virtual-machine-type-2', + default_platform=platforms[1], + default_vcpus=2, + default_memory=2048, + description='foobar2', + ), + VirtualMachineType.objects.create( + name='Virtual Machine Type 3', + slug='virtual-machine-type-3', + default_platform=platforms[2], + default_vcpus=4, + default_memory=4096, + description='foobar3', + ), + ) + + # Populate virtual_machine_count + VirtualMachine.objects.create( + name='vm-type-1a', + cluster=cluster, + virtual_machine_type=cls.vm_types[0], + ) + VirtualMachine.objects.create( + name='vm-type-1b', + cluster=cluster, + virtual_machine_type=cls.vm_types[0], + tenant=Tenant.objects.create(name='Tenant 1', slug='tenant-1'), + ) + VirtualMachine.objects.create( + name='vm-type-2a', + cluster=cluster, + virtual_machine_type=cls.vm_types[1], + ) + + def test_q(self): + params = {'q': 'foobar1'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_name(self): + params = {'name': ['Virtual Machine Type 1', 'Virtual Machine Type 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['virtual-machine-type-1', 'virtual-machine-type-2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['foobar1', 'foobar2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_default_platform(self): + platforms = Platform.objects.all()[:2] + params = {'default_platform_id': [platforms[0].pk, platforms[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'default_platform': [platforms[0].slug, platforms[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_default_vcpus(self): + params = {'default_vcpus': [1, 2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_default_memory(self): + params = {'default_memory': [1024, 2048]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_virtual_machine_count(self): + params = {'virtual_machine_count': [1, 2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VirtualMachine.objects.all() filterset = VirtualMachineFilterSet @@ -290,6 +400,30 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): for platform in platforms: platform.save() + cls.vm_types = ( + VirtualMachineType.objects.create( + name='Virtual Machine Type 1', + slug='virtual-machine-type-1', + default_platform=platforms[0], + default_vcpus=1, + default_memory=1024, + ), + VirtualMachineType.objects.create( + name='Virtual Machine Type 2', + slug='virtual-machine-type-2', + default_platform=platforms[1], + default_vcpus=2, + default_memory=2048, + ), + VirtualMachineType.objects.create( + name='Virtual Machine Type 3', + slug='virtual-machine-type-3', + default_platform=platforms[2], + default_vcpus=4, + default_memory=4096, + ), + ) + roles = ( DeviceRole(name='Device Role 1', slug='device-role-1'), DeviceRole(name='Device Role 2', slug='device-role-2'), @@ -322,6 +456,7 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): vms = ( VirtualMachine( name='Virtual Machine 1', + virtual_machine_type=cls.vm_types[0], site=sites[0], cluster=clusters[0], device=devices[0], @@ -333,11 +468,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): memory=1, disk=1, description='foobar1', - local_context_data={"foo": 123}, - serial='111-aaa' + local_context_data={'foo': 123}, + serial='111-aaa', ), VirtualMachine( name='Virtual Machine 2', + virtual_machine_type=cls.vm_types[1], site=sites[1], cluster=clusters[1], device=devices[1], @@ -354,6 +490,7 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): ), VirtualMachine( name='Virtual Machine 3', + virtual_machine_type=cls.vm_types[2], site=sites[2], cluster=clusters[2], device=devices[2], @@ -499,6 +636,13 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'platform': [platforms[0].slug, platforms[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_virtual_machine_type(self): + vm_types = VirtualMachineType.objects.all()[:2] + params = {'virtual_machine_type_id': [vm_types[0].pk, vm_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'virtual_machine_type': [vm_types[0].slug, vm_types[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_mac_address(self): params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py index 0f64e102a..b189395b1 100644 --- a/netbox/virtualization/tests/test_models.py +++ b/netbox/virtualization/tests/test_models.py @@ -1,12 +1,175 @@ +from decimal import Decimal + from django.core.exceptions import ValidationError from django.test import TestCase -from dcim.models import Site +from dcim.models import Platform, Site from tenancy.models import Tenant from utilities.testing import create_test_device from virtualization.models import * +class VirtualMachineTypeTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + cls.platform = Platform.objects.create( + name='Type Test Ubuntu 24.04', + slug='type-test-ubuntu-24-04', + ) + cls.virtual_machine_type = VirtualMachineType.objects.create( + name='Small Linux', + slug='small-linux', + default_platform=cls.platform, + default_vcpus=Decimal('2.00'), + default_memory=4096, + ) + + cls.cluster_type = ClusterType.objects.create( + name='VM Type Count Cluster Type', + slug='vm-type-count-cluster-type', + ) + cls.site = Site.objects.create( + name='VM Type Count Site', + slug='vm-type-count-site', + ) + cls.cluster = Cluster.objects.create( + name='VM Type Count Cluster', + type=cls.cluster_type, + scope=cls.site, + ) + + def test_virtual_machine_type_str_and_defaults(self): + """ + Verify that the string representation of a VirtualMachineType returns + its name, and that all default fields (platform, vcpus, memory) are + stored correctly after creation. + """ + self.assertEqual(str(self.virtual_machine_type), 'Small Linux') + self.assertEqual(self.virtual_machine_type.default_platform, self.platform) + self.assertEqual(self.virtual_machine_type.default_vcpus, Decimal('2.00')) + self.assertEqual(self.virtual_machine_type.default_memory, 4096) + + def test_virtual_machine_type_duplicate_name_case_insensitive(self): + """ + Creating a VirtualMachineType whose name differs from an existing one + only by case should fail validation, enforced by the case-insensitive + unique constraint on Lower('name'). + """ + virtual_machine_type = VirtualMachineType( + name='SMALL LINUX', + slug='small-linux-2', + ) + + with self.assertRaises(ValidationError): + virtual_machine_type.full_clean() + + def test_virtual_machine_type_duplicate_slug(self): + """ + Creating a VirtualMachineType with a slug that already exists should + fail validation, enforced by the unique constraint on the slug field. + """ + virtual_machine_type = VirtualMachineType( + name='Medium Linux', + slug='small-linux', + ) + + with self.assertRaises(ValidationError): + virtual_machine_type.full_clean() + + def test_virtual_machine_type_virtual_machine_count(self): + """ + The virtual_machine_count counter cache field should accurately track + the number of VirtualMachines referencing this type through creation, + additional insertions, reassignment, and deletion. + """ + # Starts at zero + self.assertEqual(self.virtual_machine_type.virtual_machine_count, 0) + + # Create the first VM + vm1 = VirtualMachine.objects.create( + name='vm-count-test-1', + cluster=self.cluster, + virtual_machine_type=self.virtual_machine_type, + ) + self.virtual_machine_type.refresh_from_db() + self.assertEqual(self.virtual_machine_type.virtual_machine_count, 1) + + # Create the second VM + vm2 = VirtualMachine.objects.create( + name='vm-count-test-2', + cluster=self.cluster, + virtual_machine_type=self.virtual_machine_type, + ) + self.virtual_machine_type.refresh_from_db() + self.assertEqual(self.virtual_machine_type.virtual_machine_count, 2) + + # Delete one VM — count should decrement + vm1.delete() + self.virtual_machine_type.refresh_from_db() + self.assertEqual(self.virtual_machine_type.virtual_machine_count, 1) + + # Reassign the remaining VM to no type — count should drop to zero + vm2.virtual_machine_type = None + vm2.save() + self.virtual_machine_type.refresh_from_db() + self.assertEqual(self.virtual_machine_type.virtual_machine_count, 0) + + def test_virtual_machine_type_deletion_protected(self): + """ + Deleting a VirtualMachineType that is referenced by a VM should be prevented. + """ + VirtualMachine.objects.create( + name='vm-protect-test', + cluster=self.cluster, + virtual_machine_type=self.virtual_machine_type, + ) + + from django.db import models + + with self.assertRaises(models.ProtectedError): + self.virtual_machine_type.delete() + + def test_virtual_machine_type_deletion_without_vms(self): + """ + A VirtualMachineType with no associated VMs can be deleted. + """ + vmt = VirtualMachineType.objects.create( + name='Disposable Type', + slug='disposable-type', + ) + pk = vmt.pk + vmt.delete() + self.assertFalse(VirtualMachineType.objects.filter(pk=pk).exists()) + + def test_virtual_machine_type_invalid_default_vcpus(self): + """ + default_vcpus below the minimum should fail validation. + """ + vmt = VirtualMachineType( + name='Zero vCPU Type', + slug='zero-vcpu-type', + default_vcpus=Decimal('0.00'), + ) + with self.assertRaises(ValidationError): + vmt.full_clean() + + def test_virtual_machine_type_minimal_fields(self): + """ + A VirtualMachineType with only name and slug should be valid. + """ + vmt = VirtualMachineType( + name='Bare Minimum', + slug='bare-minimum', + ) + vmt.full_clean() + vmt.save() + + self.assertIsNone(vmt.default_platform) + self.assertIsNone(vmt.default_vcpus) + self.assertIsNone(vmt.default_memory) + + class VirtualMachineTestCase(TestCase): @classmethod @@ -14,6 +177,12 @@ class VirtualMachineTestCase(TestCase): # Create the cluster type cls.cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + # Create platforms + cls.platforms = ( + Platform.objects.create(name='VM Default Ubuntu 24.04', slug='vm-default-ubuntu-24-04'), + Platform.objects.create(name='VM Default Debian 12', slug='vm-default-debian-12'), + ) + # Create sites cls.sites = ( Site.objects.create(name='Site 1', slug='site-1'), @@ -59,6 +228,24 @@ class VirtualMachineTestCase(TestCase): Tenant.objects.create(name='Tenant 2', slug='tenant-2'), ) + # Create virtual machine types + cls.vm_types = ( + VirtualMachineType.objects.create( + name='General Purpose Small', + slug='general-purpose-small', + default_platform=cls.platforms[0], + default_vcpus=Decimal('2.00'), + default_memory=4096, + ), + VirtualMachineType.objects.create( + name='General Purpose Large', + slug='general-purpose-large', + default_platform=cls.platforms[1], + default_vcpus=Decimal('8.00'), + default_memory=16384, + ), + ) + def test_vm_duplicate_name_per_cluster(self): """ Test that creating two Virtual Machines with the same name in @@ -157,6 +344,181 @@ class VirtualMachineTestCase(TestCase): with self.assertRaises(ValidationError): vm.full_clean() + # + # Virtual Machine Type tests + # + + def test_vm_type_defaults_applied_on_create(self): + """ + When a new VirtualMachine is created with a VirtualMachineType and no + explicit platform, vcpus, or memory, the type's defaults should be + automatically applied via apply_type_defaults(). + """ + vm = VirtualMachine( + name='vm-type-defaults', + cluster=self.cluster_with_site, + virtual_machine_type=self.vm_types[0], + ) + vm.full_clean() + vm.save() + vm.refresh_from_db() + + self.assertEqual(vm.platform, self.platforms[0]) + self.assertEqual(vm.vcpus, Decimal('2.00')) + self.assertEqual(vm.memory, 4096) + + def test_vm_type_defaults_do_not_override_explicit_values(self): + """ + When a new VirtualMachine specifies explicit values for a platform, + vcpus, and memory, those values must be preserved even if the + assigned VirtualMachineType defines different defaults. + """ + vm = VirtualMachine( + name='vm-type-explicit', + cluster=self.cluster_with_site, + virtual_machine_type=self.vm_types[0], + platform=self.platforms[1], + vcpus=Decimal('4.00'), + memory=8192, + ) + vm.full_clean() + vm.save() + vm.refresh_from_db() + + self.assertEqual(vm.platform, self.platforms[1]) + self.assertEqual(vm.vcpus, Decimal('4.00')) + self.assertEqual(vm.memory, 8192) + + def test_vm_type_added_to_existing_vm_does_not_backfill_defaults(self): + """ + Assigning a VirtualMachineType to an already-saved VirtualMachine + (i.e. an update, not a creation) must not retroactively populate + the VM's fields with the type's defaults, since apply_type_defaults() + only runs on initial creation. + """ + vm = VirtualMachine( + name='vm-type-added-later', + cluster=self.cluster_with_site, + ) + vm.full_clean() + vm.save() + + vm.virtual_machine_type = self.vm_types[0] + vm.full_clean() + vm.save() + vm.refresh_from_db() + + self.assertEqual(vm.virtual_machine_type, self.vm_types[0]) + self.assertIsNone(vm.platform) + self.assertIsNone(vm.vcpus) + self.assertIsNone(vm.memory) + + def test_vm_type_change_does_not_overwrite_existing_values(self): + """ + Changing the VirtualMachineType on an existing VirtualMachine must + not overwrite field values that were previously set — either + explicitly or via earlier type defaults. + """ + vm = VirtualMachine( + name='vm-type-change', + cluster=self.cluster_with_site, + virtual_machine_type=self.vm_types[0], + ) + vm.full_clean() + vm.save() + vm.refresh_from_db() + + self.assertEqual(vm.platform, self.platforms[0]) + self.assertEqual(vm.vcpus, Decimal('2.00')) + self.assertEqual(vm.memory, 4096) + + vm.platform = self.platforms[1] + vm.vcpus = Decimal('6.00') + vm.memory = 12288 + vm.virtual_machine_type = self.vm_types[1] + vm.full_clean() + vm.save() + vm.refresh_from_db() + + self.assertEqual(vm.platform, self.platforms[1]) + self.assertEqual(vm.vcpus, Decimal('6.00')) + self.assertEqual(vm.memory, 12288) + self.assertEqual(vm.virtual_machine_type, self.vm_types[1]) + + def test_vm_type_partial_defaults(self): + """ + A VirtualMachineType with only some defaults set should only populate + those fields on a new VM, leaving the rest as None. + """ + partial_type = VirtualMachineType.objects.create( + name='Partial Defaults', + slug='partial-defaults', + default_vcpus=Decimal('4.00'), + # default_platform and default_memory intentionally left None + ) + + vm = VirtualMachine( + name='vm-partial-defaults', + cluster=self.cluster_with_site, + virtual_machine_type=partial_type, + ) + vm.full_clean() + vm.save() + vm.refresh_from_db() + + self.assertIsNone(vm.platform) + self.assertEqual(vm.vcpus, Decimal('4.00')) + self.assertIsNone(vm.memory) + + def test_vm_type_no_defaults(self): + """ + A VirtualMachineType with all default fields as None should not + alter any VM fields on creation. + """ + empty_type = VirtualMachineType.objects.create( + name='Empty Type', + slug='empty-type', + ) + + vm = VirtualMachine( + name='vm-empty-type', + cluster=self.cluster_with_site, + virtual_machine_type=empty_type, + ) + vm.full_clean() + vm.save() + vm.refresh_from_db() + + self.assertEqual(vm.virtual_machine_type, empty_type) + self.assertIsNone(vm.platform) + self.assertIsNone(vm.vcpus) + self.assertIsNone(vm.memory) + + def test_vm_created_without_type(self): + """ + A VM created without a VirtualMachineType should not raise any errors + in apply_type_defaults() and should leave all fields as None. + """ + vm = VirtualMachine( + name='vm-no-type', + cluster=self.cluster_with_site, + ) + vm.full_clean() + vm.save() + vm.refresh_from_db() + + self.assertIsNone(vm.virtual_machine_type) + self.assertIsNone(vm.platform) + self.assertIsNone(vm.vcpus) + self.assertIsNone(vm.memory) + + def test_vm_type_is_included_in_clone_fields(self): + """ + Verify that virtual_machine_type is part of clone_fields so it + carries over when cloning a VM. + """ + self.assertIn('virtual_machine_type', VirtualMachine.clone_fields) + # # Device assignment tests # diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 12a4eca4b..dbd152637 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse @@ -202,6 +204,81 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): self.assertHttpStatus(self.client.get(url), 200) +class VirtualMachineTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = VirtualMachineType + + @classmethod + def setUpTestData(cls): + + cls.platforms = ( + Platform(name='Platform 1', slug='platform-1'), + Platform(name='Platform 2', slug='platform-2'), + Platform(name='Platform 3', slug='platform-3'), + ) + for platform in cls.platforms: + platform.save() + + cls.virtual_machine_types = ( + VirtualMachineType( + name='Virtual Machine Type 1', + slug='virtual-machine-type-1', + default_platform=cls.platforms[0], + default_vcpus=Decimal('1.00'), + default_memory=1024, + ), + VirtualMachineType( + name='Virtual Machine Type 2', + slug='virtual-machine-type-2', + default_platform=cls.platforms[1], + default_vcpus=Decimal('2.00'), + default_memory=2048, + ), + VirtualMachineType( + name='Virtual Machine Type 3', + slug='virtual-machine-type-3', + default_platform=cls.platforms[2], + default_vcpus=Decimal('4.00'), + default_memory=4096, + ), + ) + for virtual_machine_type in cls.virtual_machine_types: + virtual_machine_type.save() + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Virtual Machine Type X', + 'slug': 'virtual-machine-type-x', + 'default_platform': cls.platforms[1].pk, + 'default_vcpus': 8, + 'default_memory': 8192, + 'description': 'A new virtual machine type', + 'comments': 'Some comments', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + 'name,slug,default_platform,default_vcpus,default_memory,description', + 'Virtual Machine Type 4,virtual-machine-type-4,Platform 1,1.00,1024,Fourth virtual machine type', + 'Virtual Machine Type 5,virtual-machine-type-5,Platform 2,2.00,2048,Fifth virtual machine type', + 'Virtual Machine Type 6,virtual-machine-type-6,Platform 3,4.00,4096,Sixth virtual machine type', + ) + + cls.csv_update_data = ( + 'id,name,description', + f'{cls.virtual_machine_types[0].pk},Virtual Machine Type 7,New description 7', + f'{cls.virtual_machine_types[1].pk},Virtual Machine Type 8,New description 8', + f'{cls.virtual_machine_types[2].pk},Virtual Machine Type 9,New description 9', + ) + + cls.bulk_edit_data = { + 'default_platform': cls.platforms[2].pk, + 'default_vcpus': 16, + 'default_memory': 16384, + 'description': 'New description', + } + + class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VirtualMachine @@ -215,57 +292,79 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): for role in roles: role.save() - platforms = ( + cls.platforms = ( Platform(name='Platform 1', slug='platform-1'), Platform(name='Platform 2', slug='platform-2'), ) - for platform in platforms: + for platform in cls.platforms: platform.save() - sites = ( + cls.sites = ( Site(name='Site 1', slug='site-1'), Site(name='Site 2', slug='site-2'), ) - Site.objects.bulk_create(sites) + Site.objects.bulk_create(cls.sites) clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') - clusters = ( - Cluster(name='Cluster 1', type=clustertype, scope=sites[0]), - Cluster(name='Cluster 2', type=clustertype, scope=sites[1]), + cls.clusters = ( + Cluster(name='Cluster 1', type=clustertype, scope=cls.sites[0]), + Cluster(name='Cluster 2', type=clustertype, scope=cls.sites[1]), ) - for cluster in clusters: + for cluster in cls.clusters: cluster.save() - devices = ( - create_test_device('device1', site=sites[0], cluster=clusters[0]), - create_test_device('device2', site=sites[1], cluster=clusters[1]), + cls.devices = ( + create_test_device('device1', site=cls.sites[0], cluster=cls.clusters[0]), + create_test_device('device2', site=cls.sites[1], cluster=cls.clusters[1]), ) + cls.vm_types = ( + VirtualMachineType( + name='Virtual Machine Type 1', + slug='virtual-machine-type-1', + default_platform=cls.platforms[0], + default_vcpus=Decimal('2.00'), + default_memory=4096, + ), + VirtualMachineType( + name='Virtual Machine Type 2', + slug='virtual-machine-type-2', + default_platform=cls.platforms[1], + default_vcpus=Decimal('4.00'), + default_memory=8192, + ), + ) + for vm_type in cls.vm_types: + vm_type.save() + virtual_machines = ( VirtualMachine( name='Virtual Machine 1', - site=sites[0], - cluster=clusters[0], - device=devices[0], + virtual_machine_type=cls.vm_types[0], + site=cls.sites[0], + cluster=cls.clusters[0], + device=cls.devices[0], role=roles[0], - platform=platforms[0], + platform=cls.platforms[0], ), VirtualMachine( name='Virtual Machine 2', - site=sites[0], - cluster=clusters[0], - device=devices[0], + virtual_machine_type=cls.vm_types[0], + site=cls.sites[0], + cluster=cls.clusters[0], + device=cls.devices[0], role=roles[0], - platform=platforms[0], + platform=cls.platforms[0], ), VirtualMachine( name='Virtual Machine 3', - site=sites[0], - cluster=clusters[0], - device=devices[0], + virtual_machine_type=cls.vm_types[1], + site=cls.sites[0], + cluster=cls.clusters[0], + device=cls.devices[0], role=roles[0], - platform=platforms[0], + platform=cls.platforms[0], ), ) VirtualMachine.objects.bulk_create(virtual_machines) @@ -273,11 +372,12 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { - 'cluster': clusters[1].pk, - 'device': devices[1].pk, - 'site': sites[1].pk, + 'virtual_machine_type': cls.vm_types[1].pk, + 'cluster': cls.clusters[1].pk, + 'device': cls.devices[1].pk, + 'site': cls.sites[1].pk, 'tenant': None, - 'platform': platforms[1].pk, + 'platform': cls.platforms[1].pk, 'name': 'Virtual Machine X', 'status': VirtualMachineStatusChoices.STATUS_STAGED, 'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_ON, @@ -294,34 +394,61 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,status,site,cluster,device", - "Virtual Machine 4,active,Site 1,Cluster 1,device1", - "Virtual Machine 5,active,Site 1,Cluster 1,device1", - "Virtual Machine 6,active,Site 1,Cluster 1,", + 'name,status,site,cluster,device,virtual_machine_type', + 'Virtual Machine 4,active,Site 1,Cluster 1,device1,Virtual Machine Type 1', + 'Virtual Machine 5,active,Site 1,Cluster 1,device1,Virtual Machine Type 2', + 'Virtual Machine 6,active,Site 1,Cluster 1,,Virtual Machine Type 1', ) cls.csv_update_data = ( - "id,name,comments", - f"{virtual_machines[0].pk},Virtual Machine 7,New comments 7", - f"{virtual_machines[1].pk},Virtual Machine 8,New comments 8", - f"{virtual_machines[2].pk},Virtual Machine 9,New comments 9", + 'id,name,comments', + f'{virtual_machines[0].pk},Virtual Machine 7,New comments 7', + f'{virtual_machines[1].pk},Virtual Machine 8,New comments 8', + f'{virtual_machines[2].pk},Virtual Machine 9,New comments 9', ) cls.bulk_edit_data = { - 'site': sites[1].pk, - 'cluster': clusters[1].pk, - 'device': devices[1].pk, + 'virtual_machine_type': cls.vm_types[1].pk, + 'site': cls.sites[1].pk, + 'cluster': cls.clusters[1].pk, + 'device': cls.devices[1].pk, 'tenant': None, - 'platform': platforms[1].pk, + 'platform': cls.platforms[1].pk, 'status': VirtualMachineStatusChoices.STATUS_STAGED, 'role': roles[1].pk, - 'vcpus': 8, + 'vcpus': Decimal('8.00'), 'memory': 65535, 'disk': 8000, 'comments': 'New comments', 'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_OFF, } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) + def test_create_virtualmachine_with_type_defaults(self): + self.add_permissions('virtualization.add_virtualmachine') + + response = self.client.post( + self._get_url('add'), + data={ + 'name': 'Virtual Machine Defaults', + 'virtual_machine_type': self.vm_types[0].pk, + 'status': VirtualMachineStatusChoices.STATUS_ACTIVE, + 'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_OFF, + 'site': self.sites[0].pk, + 'cluster': self.clusters[0].pk, + 'platform': '', + 'vcpus': '', + 'memory': '', + }, + ) + self.assertHttpStatus(response, 302) + + vm = VirtualMachine.objects.get(name='Virtual Machine Defaults') + self.assertEqual(vm.virtual_machine_type, self.vm_types[0]) + self.assertEqual(vm.platform, self.platforms[0]) + self.assertEqual(vm.vcpus, self.vm_types[0].default_vcpus) + self.assertEqual(vm.memory, self.vm_types[0].default_memory) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_virtualmachine_interfaces(self): virtualmachine = VirtualMachine.objects.first() diff --git a/netbox/virtualization/ui/panels.py b/netbox/virtualization/ui/panels.py index 799499cdd..d4e2bd213 100644 --- a/netbox/virtualization/ui/panels.py +++ b/netbox/virtualization/ui/panels.py @@ -2,6 +2,10 @@ from django.utils.translation import gettext_lazy as _ from netbox.ui import attrs, panels +# +# Cluster +# + class ClusterPanel(panels.ObjectAttributesPanel): name = attrs.TextAttr('name') @@ -13,8 +17,27 @@ class ClusterPanel(panels.ObjectAttributesPanel): scope = attrs.GenericForeignKeyAttr('scope', linkify=True) +# +# Virtual machine types +# + + +class VirtualMachineTypePanel(panels.ObjectAttributesPanel): + name = attrs.TextAttr('name') + default_platform = attrs.RelatedObjectAttr('default_platform', linkify=True) + default_vcpus = attrs.TextAttr('default_vcpus', label=_('Default vCPUs')) + default_memory = attrs.TextAttr('default_memory', format_string=_('{} MB'), label=_('Default memory')) + description = attrs.TextAttr('description') + + +# +# Virtual machines +# + + class VirtualMachinePanel(panels.ObjectAttributesPanel): name = attrs.TextAttr('name') + virtual_machine_type = attrs.RelatedObjectAttr('virtual_machine_type', linkify=True, label=_('Type')) status = attrs.ChoiceAttr('status') start_on_boot = attrs.ChoiceAttr('start_on_boot') role = attrs.RelatedObjectAttr('role', linkify=True) @@ -44,6 +67,11 @@ class VirtualMachinePlacementPanel(panels.ObjectAttributesPanel): device = attrs.RelatedObjectAttr('device', linkify=True) +# +# Virtual disks +# + + class VirtualDiskPanel(panels.ObjectAttributesPanel): virtual_machine = attrs.RelatedObjectAttr('virtual_machine', linkify=True, label=_('Virtual Machine')) name = attrs.TextAttr('name') @@ -51,6 +79,11 @@ class VirtualDiskPanel(panels.ObjectAttributesPanel): description = attrs.TextAttr('description') +# +# VM interfaces +# + + class VMInterfacePanel(panels.ObjectAttributesPanel): virtual_machine = attrs.RelatedObjectAttr('virtual_machine', linkify=True, label=_('Virtual Machine')) name = attrs.TextAttr('name') diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 725260529..26090a408 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -16,6 +16,9 @@ urlpatterns = [ path('clusters/', include(get_model_urls('virtualization', 'cluster', detail=False))), path('clusters//', include(get_model_urls('virtualization', 'cluster'))), + path('virtual-machine-types/', include(get_model_urls('virtualization', 'virtualmachinetype', detail=False))), + path('virtual-machine-types//', include(get_model_urls('virtualization', 'virtualmachinetype'))), + path('virtual-machines/', include(get_model_urls('virtualization', 'virtualmachine', detail=False))), path('virtual-machines//', include(get_model_urls('virtualization', 'virtualmachine'))), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index c3dbd1589..27cd0cb98 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -387,6 +387,80 @@ class ClusterAddDevicesView(generic.ObjectEditView): }) +# +# Virtual machine types +# + + +@register_model_view(VirtualMachineType, 'list', path='', detail=False) +class VirtualMachineTypeListView(generic.ObjectListView): + queryset = VirtualMachineType.objects.all() + filterset = filtersets.VirtualMachineTypeFilterSet + filterset_form = forms.VirtualMachineTypeFilterForm + table = tables.VirtualMachineTypeTable + + +@register_model_view(VirtualMachineType) +class VirtualMachineTypeView(GetRelatedModelsMixin, generic.ObjectView): + queryset = VirtualMachineType.objects.all() + layout = layout.SimpleLayout( + left_panels=[ + panels.VirtualMachineTypePanel(), + TagsPanel(), + CommentsPanel(), + ], + right_panels=[ + RelatedObjectsPanel(), + CustomFieldsPanel(), + ImageAttachmentsPanel(), + ], + ) + + def get_extra_context(self, request, instance): + return { + 'related_models': self.get_related_models(request, instance), + } + + +@register_model_view(VirtualMachineType, 'add', detail=False) +@register_model_view(VirtualMachineType, 'edit') +class VirtualMachineTypeEditView(generic.ObjectEditView): + queryset = VirtualMachineType.objects.all() + form = forms.VirtualMachineTypeForm + + +@register_model_view(VirtualMachineType, 'delete') +class VirtualMachineTypeDeleteView(generic.ObjectDeleteView): + queryset = VirtualMachineType.objects.all() + + +@register_model_view(VirtualMachineType, 'bulk_import', path='import', detail=False) +class VirtualMachineTypeBulkImportView(generic.BulkImportView): + queryset = VirtualMachineType.objects.all() + model_form = forms.VirtualMachineTypeImportForm + + +@register_model_view(VirtualMachineType, 'bulk_edit', path='edit', detail=False) +class VirtualMachineTypeBulkEditView(generic.BulkEditView): + queryset = VirtualMachineType.objects.all() + filterset = filtersets.VirtualMachineTypeFilterSet + table = tables.VirtualMachineTypeTable + form = forms.VirtualMachineTypeBulkEditForm + + +@register_model_view(VirtualMachineType, 'bulk_rename', path='rename', detail=False) +class VirtualMachineTypeBulkRenameView(generic.BulkRenameView): + queryset = VirtualMachineType.objects.all() + filterset = filtersets.VirtualMachineTypeFilterSet + + +@register_model_view(VirtualMachineType, 'bulk_delete', path='delete', detail=False) +class VirtualMachineTypeBulkDeleteView(generic.BulkDeleteView): + queryset = VirtualMachineType.objects.all() + filterset = filtersets.VirtualMachineTypeFilterSet + table = tables.VirtualMachineTypeTable + + # # Virtual machines #