Compare commits

..

2 Commits

Author SHA1 Message Date
Martin Hauser
32137117c9 feat(dcim): Add changelog message support to bulk component creation
Add ChangelogMessageMixin to DeviceBulkAddComponentForm and capture
changelog_message during bulk component creation. Ensure message is
applied to each created component instance. Add test coverage for
changelog message propagation.
2026-03-27 09:51:11 +01:00
Jeremy Stretch
296b89ae02 Fixes #21747: Skip search caching when encountering an invalid schema during migrations (#21748) 2026-03-26 16:46:41 -04:00
35 changed files with 194 additions and 1659 deletions

View File

@@ -74,7 +74,6 @@ 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)
@@ -94,7 +93,6 @@ 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)

View File

@@ -5,37 +5,27 @@ Virtual machines, clusters, and standalone hypervisors can be modeled in NetBox
```mermaid
flowchart TD
ClusterGroup & ClusterType --> Cluster
VirtualMachineType --> VirtualMachine
Device --> VirtualMachine
Cluster --> VirtualMachine
Device --> VirtualMachine
Platform --> VirtualMachine
VirtualMachine --> 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/"
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/"
```
## 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.
## 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.
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 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 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 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 be placed in one of three ways:

View File

@@ -13,12 +13,6 @@ 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.
@@ -51,7 +45,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. If a virtual machine type defines a default platform, it will be applied when the VM is created unless an explicit platform is specified.
A VM may be associated with a particular [platform](../dcim/platform.md) to indicate its operating system.
### Primary IPv4 & IPv6 Addresses
@@ -62,11 +56,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). 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.
The number of virtual CPUs provisioned. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU).
### Memory
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.
The amount of running memory provisioned, in megabytes.
### Disk

View File

@@ -1,27 +0,0 @@
# 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.

View File

@@ -285,10 +285,9 @@ 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'

View File

@@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import *
from extras.models import Tag
from netbox.forms.mixins import CustomFieldsMixin
from netbox.forms.mixins import ChangelogMessageMixin, CustomFieldsMixin
from utilities.forms import form_from_model
from utilities.forms.fields import DynamicModelMultipleChoiceField, ExpandableNameField
@@ -27,7 +27,8 @@ __all__ = (
# Device components
#
class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm):
class DeviceBulkAddComponentForm(ChangelogMessageMixin, CustomFieldsMixin, ComponentCreateForm):
pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput()

View File

@@ -3,11 +3,13 @@ from decimal import Decimal
from zoneinfo import ZoneInfo
import yaml
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings, tag
from django.urls import reverse
from netaddr import EUI
from core.models import ObjectType
from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange, ObjectType
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
@@ -2741,6 +2743,50 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
f"{console_ports[2].pk},Console Port 9,New description9",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_bulk_add_components_with_changelog_message(self):
device1 = Device.objects.get(name='Device 1')
device2 = create_test_device('Device 2')
changelog_message = 'Bulk-created console ports'
obj_perm = ObjectPermission(
name='Test permission',
actions=['add'],
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
request = {
'path': reverse('dcim:device_bulk_add_consoleport'),
'data': post_data({
'pk': [device1.pk, device2.pk],
'name': 'Console Port Bulk',
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'Bulk-created console port',
'changelog_message': changelog_message,
'_create': True,
}),
}
initial_count = self._get_queryset().count()
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
self.assertEqual(initial_count + 2, self._get_queryset().count())
created_ports = list(ConsolePort.objects.filter(name='Console Port Bulk').order_by('device_id'))
self.assertEqual(len(created_ports), 2)
self.assertEqual([port.device_id for port in created_ports], [device1.pk, device2.pk])
objectchanges = ObjectChange.objects.filter(
action=ObjectChangeActionChoices.ACTION_CREATE,
changed_object_type=ContentType.objects.get_for_model(ConsolePort),
changed_object_id__in=[port.pk for port in created_ports],
)
self.assertEqual(objectchanges.count(), 2)
for objectchange in objectchanges:
self.assertEqual(objectchange.message, changelog_message)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
consoleport = ConsolePort.objects.first()

View File

@@ -1305,7 +1305,6 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
'virtualdevicecontext',
'virtualdisk',
'virtualmachine',
'virtualmachinetype',
'vlan',
'vlangroup',
'vlantranslationpolicy',

View File

@@ -270,12 +270,6 @@ 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=(

View File

@@ -1,9 +1,11 @@
import logging
from collections import defaultdict
import netaddr
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ImproperlyConfigured
from django.db import ProgrammingError
from django.db.models import F, Q, Window, prefetch_related_objects
from django.db.models.fields.related import ForeignKey
from django.db.models.functions import window
@@ -24,6 +26,8 @@ from . import FieldTypes, LookupTypes, get_indexer
DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL
MAX_RESULTS = 1000
logger = logging.getLogger(__name__)
class SearchBackend:
"""
@@ -63,7 +67,12 @@ class SearchBackend:
"""
Receiver for the post_save signal, responsible for caching object creation/changes.
"""
self.cache(instance, remove_existing=not created)
try:
self.cache(instance, remove_existing=not created)
except ProgrammingError as e:
# The schema may be incomplete during migrations; skip caching.
logger.warning(f"Skipping search cache update due to schema error: {e}")
pass
def removal_handler(self, sender, instance, **kwargs):
"""

View File

@@ -1139,6 +1139,7 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
new_components = []
data = deepcopy(form.cleaned_data)
changelog_message = data.pop('changelog_message', '')
replication_data = {
field: data.pop(field) for field in form.replication_fields
}
@@ -1160,6 +1161,8 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
component_form = self.model_form(component_data)
if component_form.is_valid():
if changelog_message:
component_form.instance._changelog_message = changelog_message
instance = component_form.save()
logger.debug(f"Created {instance} on {instance.parent_object}")
new_components.append(instance)

View File

@@ -1,10 +0,0 @@
{% extends 'generic/object.html' %}
{% load i18n %}
{% block extra_controls %}
{% if perms.virtualization.add_virtualmachine %}
<a href="{% url 'virtualization:virtualmachine_add' %}?virtual_machine_type={{ object.pk }}" class="btn btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Virtual Machine" %}
</a>
{% endif %}
{% endblock extra_controls %}

View File

@@ -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,29 +27,11 @@ __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)
@@ -70,10 +52,10 @@ class VirtualMachineSerializer(PrimaryModelSerializer):
class Meta:
model = VirtualMachine
fields = [
'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',
'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',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
@@ -83,11 +65,10 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
class Meta(VirtualMachineSerializer.Meta):
fields = [
'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',
'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',
]
@extend_schema_field(serializers.JSONField(allow_null=True))

View File

@@ -10,9 +10,6 @@ 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)

View File

@@ -14,7 +14,6 @@ class VirtualizationRootView(APIRootView):
"""
Virtualization API root view
"""
def get_view_name(self):
return 'Virtualization'
@@ -23,7 +22,6 @@ class VirtualizationRootView(APIRootView):
# Clusters
#
class ClusterTypeViewSet(NetBoxModelViewSet):
queryset = ClusterType.objects.all()
serializer_class = serializers.ClusterTypeSerializer
@@ -46,22 +44,10 @@ 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

View File

@@ -9,10 +9,10 @@ class VirtualizationConfig(AppConfig):
from utilities.counters import connect_counters
from . import search, signals # noqa: F401
from .models import VirtualMachine, VirtualMachineType
from .models import VirtualMachine
# Register models
register_models(*self.get_models())
# Register counters
connect_counters(VirtualMachine, VirtualMachineType)
connect_counters(VirtualMachine)

View File

@@ -26,7 +26,6 @@ __all__ = (
'VMInterfaceFilterSet',
'VirtualDiskFilterSet',
'VirtualMachineFilterSet',
'VirtualMachineTypeFilterSet',
)
@@ -92,45 +91,6 @@ 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,
@@ -139,18 +99,6 @@ 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,

View File

@@ -14,9 +14,8 @@ 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 ..choices import *
from ..models import *
from virtualization.choices import *
from virtualization.models import *
__all__ = (
'ClusterBulkEditForm',
@@ -27,7 +26,6 @@ __all__ = (
'VirtualDiskBulkEditForm',
'VirtualDiskBulkRenameForm',
'VirtualMachineBulkEditForm',
'VirtualMachineTypeBulkEditForm',
)
@@ -80,37 +78,7 @@ 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),
@@ -184,14 +152,13 @@ class VirtualMachineBulkEditForm(PrimaryModelBulkEditForm):
model = VirtualMachine
fieldsets = (
FieldSet('virtual_machine_type', 'status', 'start_on_boot', 'role', 'tenant', 'platform', 'description'),
FieldSet('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 = (
'virtual_machine_type', 'role', 'site', 'cluster', 'device', 'platform', 'vcpus', 'memory', 'disk', 'tenant',
'description', 'comments',
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'description', 'comments',
)

View File

@@ -10,9 +10,8 @@ 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 ..choices import *
from ..models import *
from virtualization.choices import *
from virtualization.models import *
__all__ = (
'ClusterGroupImportForm',
@@ -21,7 +20,6 @@ __all__ = (
'VMInterfaceImportForm',
'VirtualDiskImportForm',
'VirtualMachineImportForm',
'VirtualMachineTypeImportForm',
)
@@ -84,31 +82,7 @@ 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,
@@ -175,9 +149,8 @@ class VirtualMachineImportForm(PrimaryModelImportForm):
class Meta:
model = VirtualMachine
fields = (
'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', 'site', 'cluster', 'device',
'platform', 'vcpus', 'memory', 'disk', 'description', 'serial',
'tenant', 'owner', 'comments', 'tags', 'config_template',
'name', 'status', 'start_on_boot', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus',
'memory', 'disk', 'description', 'serial', 'config_template', 'comments', 'owner', 'tags',
)

View File

@@ -12,11 +12,10 @@ 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',
@@ -24,7 +23,6 @@ __all__ = (
'VMInterfaceFilterForm',
'VirtualDiskFilterForm',
'VirtualMachineFilterForm',
'VirtualMachineTypeFilterForm',
)
@@ -102,43 +100,6 @@ 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,
@@ -151,20 +112,13 @@ 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(
'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')
'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,

View File

@@ -14,11 +14,10 @@ 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, SlugField
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import HTMXSelect
from ..models import *
from virtualization.models import *
__all__ = (
'ClusterAddDevicesForm',
@@ -29,7 +28,6 @@ __all__ = (
'VMInterfaceForm',
'VirtualDiskForm',
'VirtualMachineForm',
'VirtualMachineTypeForm',
)
@@ -169,35 +167,7 @@ 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(),
@@ -256,10 +226,7 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm):
)
fieldsets = (
FieldSet(
'name', 'virtual_machine_type', 'role', 'status', 'start_on_boot', 'description', 'serial', 'tags',
name=_('Virtual Machine')
),
FieldSet('name', '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')),
@@ -270,9 +237,9 @@ class VirtualMachineForm(TenancyForm, PrimaryModelForm):
class Meta:
model = VirtualMachine
fields = [
'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',
'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',
]
def __init__(self, *args, **kwargs):

View File

@@ -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, ComparisonFilterLookup, StrFilterLookup
from strawberry_django import BaseFilterLookup, FilterLookup, StrFilterLookup
from dcim.graphql.filter_mixins import InterfaceBaseFilterMixin, RenderConfigFilterMixin, ScopedFilterMixin
from extras.graphql.filter_mixins import ConfigContextFilterMixin
@@ -34,7 +34,6 @@ __all__ = (
'VMInterfaceFilter',
'VirtualDiskFilter',
'VirtualMachineFilter',
'VirtualMachineTypeFilter',
)
@@ -69,24 +68,6 @@ 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,
@@ -97,9 +78,6 @@ 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 = (
@@ -114,7 +92,9 @@ 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()
)
@@ -137,6 +117,8 @@ 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()
)
@@ -147,11 +129,10 @@ class VirtualMachineFilter(
strawberry_django.filter_field()
)
start_on_boot: (
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()
BaseFilterLookup[Annotated['VirtualMachineStartOnBootEnum', strawberry.lazy('virtualization.graphql.enums')]
] | None) = (
strawberry_django.filter_field()
)
@strawberry_django.filter_type(models.VMInterface, lookups=True)

View File

@@ -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,9 +15,6 @@ 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()

View File

@@ -34,7 +34,6 @@ __all__ = (
'VMInterfaceType',
'VirtualDiskType',
'VirtualMachineType',
'VirtualMachineTypeType',
)
@@ -92,19 +91,6 @@ 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__',
@@ -115,7 +101,6 @@ 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

View File

@@ -1,106 +0,0 @@
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.',
),
),
]

View File

@@ -20,88 +20,16 @@ from utilities.fields import CounterCacheField, NaturalOrderingField
from utilities.ordering import naturalize_interface
from utilities.query_functions import CollateAsChar
from utilities.tracking import TrackingModelMixin
from ..choices import *
from virtualization.choices import *
__all__ = (
'VMInterface',
'VirtualDisk',
'VirtualMachine',
'VirtualMachineType',
)
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
):
class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, ConfigContextModel, PrimaryModel):
"""
A virtual machine which runs on a Cluster or a standalone Device.
@@ -114,15 +42,6 @@ class VirtualMachine(
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,
@@ -243,8 +162,7 @@ class VirtualMachine(
objects = ConfigContextModelQuerySet.as_manager()
clone_fields = (
'virtual_machine_type', 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory',
'disk',
'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
)
class Meta:
@@ -364,29 +282,8 @@ class VirtualMachine(
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)

View File

@@ -38,18 +38,6 @@ 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

View File

@@ -5,64 +5,28 @@ 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'),
)
@@ -121,13 +85,12 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, PrimaryModel
class Meta(PrimaryModelTable.Meta):
model = VirtualMachine
fields = (
'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',
'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',
)
default_columns = (
'pk', 'name', 'virtual_machine_type', 'role', 'status', 'site', 'cluster', 'tenant',
'vcpus', 'memory', 'disk', 'primary_ip',
'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
)
def render_disk(self, value):

View File

@@ -7,7 +7,7 @@ from rest_framework import status
from core.models import ObjectType
from dcim.choices import InterfaceModeChoices
from dcim.models import Platform, Site
from dcim.models import Site
from extras.choices import CustomFieldTypeChoices
from extras.models import ConfigTemplate, CustomField
from ipam.choices import VLANQinQRoleChoices
@@ -167,170 +167,54 @@ 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')
cls.sites = (
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(cls.sites)
Site.objects.bulk_create(sites)
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),
clusters = (
Cluster(name='Cluster 1', type=clustertype, scope=sites[0], group=clustergroup),
Cluster(name='Cluster 2', type=clustertype, scope=sites[1], group=clustergroup),
Cluster(name='Cluster 3', type=clustertype),
)
for cluster in cls.clusters:
for cluster in clusters:
cluster.save()
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,
),
)
device1 = create_test_device('device1', site=sites[0], cluster=clusters[0])
device2 = create_test_device('device2', site=sites[1], cluster=clusters[1])
virtual_machines = (
VirtualMachine(
name='Virtual Machine 1',
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,
site=sites[0],
cluster=clusters[0],
device=device1,
local_context_data={'A': 1},
),
VirtualMachine(
name='Virtual Machine 2',
site=cls.sites[0],
cluster=cls.clusters[0],
local_context_data={'B': 2},
),
site=sites[0],
cluster=clusters[0],
local_context_data={'B': 2
}),
VirtualMachine(
name='Virtual Machine 3',
site=cls.sites[0],
cluster=cls.clusters[0],
site=sites[0],
cluster=clusters[0],
local_context_data={'C': 3},
start_on_boot=VirtualMachineStartOnBootChoices.STATUS_ON,
),
@@ -340,106 +224,26 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
cls.create_data = [
{
'name': 'Virtual Machine 4',
'site': cls.sites[1].pk,
'cluster': cls.clusters[1].pk,
'device': cls.devices[1].pk,
'virtual_machine_type': cls.vm_types[0].pk,
'site': sites[1].pk,
'cluster': clusters[1].pk,
'device': device2.pk,
},
{
'name': 'Virtual Machine 5',
'site': cls.sites[1].pk,
'cluster': cls.clusters[1].pk,
'virtual_machine_type': cls.vm_types[1].pk,
'site': sites[1].pk,
'cluster': clusters[1].pk,
},
{
'name': 'Virtual Machine 6',
'site': cls.sites[1].pk,
'site': sites[1].pk,
},
{
'name': 'Virtual Machine 7',
'cluster': cls.clusters[2].pk,
'virtual_machine_type': cls.vm_types[0].pk,
'cluster': clusters[2].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.

View File

@@ -230,116 +230,6 @@ 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
@@ -400,30 +290,6 @@ 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'),
@@ -456,7 +322,6 @@ 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],
@@ -468,12 +333,11 @@ 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],
@@ -490,7 +354,6 @@ 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],
@@ -636,13 +499,6 @@ 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)

View File

@@ -1,175 +1,12 @@
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.test import TestCase
from dcim.models import Platform, Site
from dcim.models import 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
@@ -177,12 +14,6 @@ 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'),
@@ -228,24 +59,6 @@ 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
@@ -344,181 +157,6 @@ 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
#

View File

@@ -1,5 +1,3 @@
from decimal import Decimal
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
@@ -204,81 +202,6 @@ 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
@@ -292,79 +215,57 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
for role in roles:
role.save()
cls.platforms = (
platforms = (
Platform(name='Platform 1', slug='platform-1'),
Platform(name='Platform 2', slug='platform-2'),
)
for platform in cls.platforms:
for platform in platforms:
platform.save()
cls.sites = (
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(cls.sites)
Site.objects.bulk_create(sites)
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cls.clusters = (
Cluster(name='Cluster 1', type=clustertype, scope=cls.sites[0]),
Cluster(name='Cluster 2', type=clustertype, scope=cls.sites[1]),
clusters = (
Cluster(name='Cluster 1', type=clustertype, scope=sites[0]),
Cluster(name='Cluster 2', type=clustertype, scope=sites[1]),
)
for cluster in cls.clusters:
for cluster in clusters:
cluster.save()
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]),
devices = (
create_test_device('device1', site=sites[0], cluster=clusters[0]),
create_test_device('device2', site=sites[1], cluster=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',
virtual_machine_type=cls.vm_types[0],
site=cls.sites[0],
cluster=cls.clusters[0],
device=cls.devices[0],
site=sites[0],
cluster=clusters[0],
device=devices[0],
role=roles[0],
platform=cls.platforms[0],
platform=platforms[0],
),
VirtualMachine(
name='Virtual Machine 2',
virtual_machine_type=cls.vm_types[0],
site=cls.sites[0],
cluster=cls.clusters[0],
device=cls.devices[0],
site=sites[0],
cluster=clusters[0],
device=devices[0],
role=roles[0],
platform=cls.platforms[0],
platform=platforms[0],
),
VirtualMachine(
name='Virtual Machine 3',
virtual_machine_type=cls.vm_types[1],
site=cls.sites[0],
cluster=cls.clusters[0],
device=cls.devices[0],
site=sites[0],
cluster=clusters[0],
device=devices[0],
role=roles[0],
platform=cls.platforms[0],
platform=platforms[0],
),
)
VirtualMachine.objects.bulk_create(virtual_machines)
@@ -372,12 +273,11 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'virtual_machine_type': cls.vm_types[1].pk,
'cluster': cls.clusters[1].pk,
'device': cls.devices[1].pk,
'site': cls.sites[1].pk,
'cluster': clusters[1].pk,
'device': devices[1].pk,
'site': sites[1].pk,
'tenant': None,
'platform': cls.platforms[1].pk,
'platform': platforms[1].pk,
'name': 'Virtual Machine X',
'status': VirtualMachineStatusChoices.STATUS_STAGED,
'start_on_boot': VirtualMachineStartOnBootChoices.STATUS_ON,
@@ -394,61 +294,34 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
'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',
"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,",
)
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 = {
'virtual_machine_type': cls.vm_types[1].pk,
'site': cls.sites[1].pk,
'cluster': cls.clusters[1].pk,
'device': cls.devices[1].pk,
'site': sites[1].pk,
'cluster': clusters[1].pk,
'device': devices[1].pk,
'tenant': None,
'platform': cls.platforms[1].pk,
'platform': platforms[1].pk,
'status': VirtualMachineStatusChoices.STATUS_STAGED,
'role': roles[1].pk,
'vcpus': Decimal('8.00'),
'vcpus': 8,
'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()

View File

@@ -2,10 +2,6 @@ from django.utils.translation import gettext_lazy as _
from netbox.ui import attrs, panels
#
# Cluster
#
class ClusterPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
@@ -17,27 +13,8 @@ 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)
@@ -67,11 +44,6 @@ 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')
@@ -79,11 +51,6 @@ 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')

View File

@@ -16,9 +16,6 @@ urlpatterns = [
path('clusters/', include(get_model_urls('virtualization', 'cluster', detail=False))),
path('clusters/<int:pk>/', include(get_model_urls('virtualization', 'cluster'))),
path('virtual-machine-types/', include(get_model_urls('virtualization', 'virtualmachinetype', detail=False))),
path('virtual-machine-types/<int:pk>/', include(get_model_urls('virtualization', 'virtualmachinetype'))),
path('virtual-machines/', include(get_model_urls('virtualization', 'virtualmachine', detail=False))),
path('virtual-machines/<int:pk>/', include(get_model_urls('virtualization', 'virtualmachine'))),

View File

@@ -387,80 +387,6 @@ 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
#