mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Merge branch 'feature' into 9102-cabling
This commit is contained in:
@@ -84,13 +84,14 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
)
|
||||
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
|
||||
data_type = serializers.SerializerMethodField()
|
||||
ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False)
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
|
||||
'description', 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum',
|
||||
'validation_regex', 'choices', 'created', 'last_updated',
|
||||
'description', 'required', 'filter_logic', 'ui_visibility', 'default', 'weight', 'validation_minimum',
|
||||
'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
def get_data_type(self, obj):
|
||||
|
@@ -47,6 +47,19 @@ class CustomFieldFilterLogicChoices(ChoiceSet):
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldVisibilityChoices(ChoiceSet):
|
||||
|
||||
VISIBILITY_READ_WRITE = 'read-write'
|
||||
VISIBILITY_READ_ONLY = 'read-only'
|
||||
VISIBILITY_HIDDEN = 'hidden'
|
||||
|
||||
CHOICES = (
|
||||
(VISIBILITY_READ_WRITE, 'Read/Write'),
|
||||
(VISIBILITY_READ_ONLY, 'Read-only'),
|
||||
(VISIBILITY_HIDDEN, 'Hidden'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# CustomLinks
|
||||
#
|
||||
|
@@ -62,7 +62,10 @@ class CustomFieldFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = ['id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'weight', 'description']
|
||||
fields = [
|
||||
'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'ui_visibility', 'weight',
|
||||
'description',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
@@ -37,6 +37,13 @@ class CustomFieldBulkEditForm(BulkEditForm):
|
||||
weight = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
ui_visibility = forms.ChoiceField(
|
||||
label="UI visibility",
|
||||
choices=add_blank_choice(CustomFieldVisibilityChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect()
|
||||
)
|
||||
|
||||
nullable_fields = ('group_name', 'description',)
|
||||
|
||||
|
@@ -38,6 +38,7 @@ class CustomFieldCSVForm(CSVModelForm):
|
||||
fields = (
|
||||
'name', 'label', 'group_name', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic',
|
||||
'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
||||
'ui_visibility',
|
||||
)
|
||||
|
||||
|
||||
|
@@ -1,6 +1,7 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from extras.models import *
|
||||
from extras.choices import CustomFieldVisibilityChoices
|
||||
|
||||
__all__ = (
|
||||
'CustomFieldsMixin',
|
||||
@@ -42,8 +43,18 @@ class CustomFieldsMixin:
|
||||
Append form fields for all CustomFields assigned to this object type.
|
||||
"""
|
||||
for customfield in self._get_custom_fields(self._get_content_type()):
|
||||
if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
|
||||
continue
|
||||
|
||||
field_name = f'cf_{customfield.name}'
|
||||
self.fields[field_name] = self._get_form_field(customfield)
|
||||
|
||||
if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
|
||||
self.fields[field_name].disabled = True
|
||||
if self.fields[field_name].help_text:
|
||||
self.fields[field_name].help_text += '<br />'
|
||||
self.fields[field_name].help_text += '<i class="mdi mdi-alert-circle-outline"></i> ' \
|
||||
'Field is set to read-only.'
|
||||
|
||||
# Annotate the field in the list of CustomField form fields
|
||||
self.custom_fields[field_name] = customfield
|
||||
|
@@ -32,7 +32,7 @@ __all__ = (
|
||||
class CustomFieldFilterForm(FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q',)),
|
||||
('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required')),
|
||||
('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required', 'ui_visibility')),
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
@@ -56,6 +56,12 @@ class CustomFieldFilterForm(FilterForm):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
ui_visibility = forms.ChoiceField(
|
||||
choices=add_blank_choice(CustomFieldVisibilityChoices),
|
||||
required=False,
|
||||
label=_('UI visibility'),
|
||||
widget=StaticSelect()
|
||||
)
|
||||
|
||||
|
||||
class CustomLinkFilterForm(FilterForm):
|
||||
|
@@ -43,7 +43,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
('Custom Field', (
|
||||
'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description',
|
||||
)),
|
||||
('Behavior', ('filter_logic',)),
|
||||
('Behavior', ('filter_logic', 'ui_visibility')),
|
||||
('Values', ('default', 'choices')),
|
||||
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
|
||||
)
|
||||
@@ -58,6 +58,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
widgets = {
|
||||
'type': StaticSelect(),
|
||||
'filter_logic': StaticSelect(),
|
||||
'ui_visibility': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
|
18
netbox/extras/migrations/0075_customfield_ui_visibility.py
Normal file
18
netbox/extras/migrations/0075_customfield_ui_visibility.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.0.4 on 2022-05-23 20:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0074_customfield_group_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customfield',
|
||||
name='ui_visibility',
|
||||
field=models.CharField(default='read-write', max_length=50),
|
||||
),
|
||||
]
|
@@ -136,6 +136,13 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
null=True,
|
||||
help_text='Comma-separated list of available choices (for selection fields)'
|
||||
)
|
||||
ui_visibility = models.CharField(
|
||||
max_length=50,
|
||||
choices=CustomFieldVisibilityChoices,
|
||||
default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
|
||||
verbose_name='UI visibility',
|
||||
help_text='Specifies the visibility of custom field in the UI'
|
||||
)
|
||||
objects = CustomFieldManager()
|
||||
|
||||
class Meta:
|
||||
|
@@ -28,12 +28,13 @@ class CustomFieldTable(NetBoxTable):
|
||||
)
|
||||
content_types = columns.ContentTypesColumn()
|
||||
required = columns.BooleanColumn()
|
||||
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = CustomField
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default',
|
||||
'description', 'filter_logic', 'choices', 'created', 'last_updated',
|
||||
'description', 'filter_logic', 'ui_visibility', 'choices', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
|
||||
|
||||
|
@@ -36,13 +36,14 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'default': None,
|
||||
'weight': 200,
|
||||
'required': True,
|
||||
'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex',
|
||||
'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3}',
|
||||
'field5,Field 5,integer,dcim.site,100,exact,,1,100,',
|
||||
'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,',
|
||||
'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility',
|
||||
'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3},read-write',
|
||||
'field5,Field 5,integer,dcim.site,100,exact,,1,100,,read-write',
|
||||
'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,,read-write',
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
|
@@ -9,7 +9,7 @@ from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from extras.choices import ObjectChangeActionChoices
|
||||
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
|
||||
from extras.utils import register_features
|
||||
from netbox.signals import post_clean
|
||||
from utilities.utils import serialize_object
|
||||
@@ -100,7 +100,7 @@ class CustomFieldsMixin(models.Model):
|
||||
"""
|
||||
return self.custom_field_data
|
||||
|
||||
def get_custom_fields(self):
|
||||
def get_custom_fields(self, omit_hidden=False):
|
||||
"""
|
||||
Return a dictionary of custom fields for a single object in the form `{field: value}`.
|
||||
|
||||
@@ -114,6 +114,10 @@ class CustomFieldsMixin(models.Model):
|
||||
|
||||
data = {}
|
||||
for field in CustomField.objects.get_for_model(self):
|
||||
# Skip fields that are hidden if 'omit_hidden' is set
|
||||
if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
|
||||
continue
|
||||
|
||||
value = self.custom_field_data.get(field.name)
|
||||
data[field] = field.deserialize(value)
|
||||
|
||||
@@ -121,10 +125,10 @@ class CustomFieldsMixin(models.Model):
|
||||
|
||||
def get_custom_fields_by_group(self):
|
||||
"""
|
||||
Return a dictionary of custom field/value mappings organized by group.
|
||||
Return a dictionary of custom field/value mappings organized by group. Hidden fields are omitted.
|
||||
"""
|
||||
grouped_custom_fields = defaultdict(dict)
|
||||
for cf, value in self.get_custom_fields().items():
|
||||
for cf, value in self.get_custom_fields(omit_hidden=True).items():
|
||||
grouped_custom_fields[cf.group_name][cf] = value
|
||||
|
||||
return dict(grouped_custom_fields)
|
||||
|
@@ -7,6 +7,7 @@ from django.db.models.fields.related import RelatedField
|
||||
from django_tables2.data import TableQuerysetData
|
||||
|
||||
from extras.models import CustomField, CustomLink
|
||||
from extras.choices import CustomFieldVisibilityChoices
|
||||
from netbox.tables import columns
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
|
||||
@@ -178,7 +179,10 @@ class NetBoxTable(BaseTable):
|
||||
|
||||
# Add custom field & custom link columns
|
||||
content_type = ContentType.objects.get_for_model(self._meta.model)
|
||||
custom_fields = CustomField.objects.filter(content_types=content_type)
|
||||
custom_fields = CustomField.objects.filter(
|
||||
content_types=content_type
|
||||
).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN)
|
||||
|
||||
extra_columns.extend([
|
||||
(f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
|
||||
])
|
||||
|
@@ -42,6 +42,14 @@
|
||||
<th scope="row">Weight</th>
|
||||
<td>{{ object.weight }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Filter Logic</th>
|
||||
<td>{{ object.get_filter_logic_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">UI Visibility</th>
|
||||
<td>{{ object.get_ui_visibility_display }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,10 +73,6 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Filter Logic</th>
|
||||
<td>{{ object.get_filter_logic_display }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -78,31 +78,39 @@
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Cluster
|
||||
</h5>
|
||||
<h5 class="card-header">Cluster</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Site</th>
|
||||
<td>
|
||||
{{ object.site|linkify|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Cluster</th>
|
||||
<td>
|
||||
{% if object.cluster.group %}
|
||||
{{ object.cluster.group|linkify }} /
|
||||
{% endif %}
|
||||
{{ object.cluster|linkify }}
|
||||
{{ object.cluster|linkify|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Cluster Type</th>
|
||||
<td>{{ object.cluster.type }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Device</th>
|
||||
<td>
|
||||
{{ object.device|linkify|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Resources
|
||||
</h5>
|
||||
<h5 class="card-header">Resources</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
|
@@ -34,15 +34,16 @@ def post_data(data):
|
||||
return ret
|
||||
|
||||
|
||||
def create_test_device(name):
|
||||
def create_test_device(name, site=None, **attrs):
|
||||
"""
|
||||
Convenience method for creating a Device (e.g. for component testing).
|
||||
"""
|
||||
site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1')
|
||||
if site is None:
|
||||
site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1')
|
||||
manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer)
|
||||
devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1')
|
||||
device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole)
|
||||
device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole, **attrs)
|
||||
|
||||
return device
|
||||
|
||||
|
@@ -1,7 +1,9 @@
|
||||
from drf_yasg.utils import swagger_serializer_method
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
|
||||
from dcim.api.nested_serializers import (
|
||||
NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer,
|
||||
)
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer
|
||||
from ipam.models import VLAN
|
||||
@@ -45,6 +47,7 @@ class ClusterSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
|
||||
type = NestedClusterTypeSerializer()
|
||||
group = NestedClusterGroupSerializer(required=False, allow_null=True, default=None)
|
||||
status = ChoiceField(choices=ClusterStatusChoices, required=False)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
site = NestedSiteSerializer(required=False, allow_null=True, default=None)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
@@ -53,8 +56,8 @@ class ClusterSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = Cluster
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'device_count', 'virtualmachine_count',
|
||||
'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
|
||||
]
|
||||
|
||||
|
||||
@@ -65,8 +68,9 @@ class ClusterSerializer(NetBoxModelSerializer):
|
||||
class VirtualMachineSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
|
||||
status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
|
||||
site = NestedSiteSerializer(read_only=True)
|
||||
cluster = NestedClusterSerializer()
|
||||
site = NestedSiteSerializer(required=False, allow_null=True)
|
||||
cluster = NestedClusterSerializer(required=False, allow_null=True)
|
||||
device = NestedDeviceSerializer(required=False, allow_null=True)
|
||||
role = NestedDeviceRoleSerializer(required=False, allow_null=True)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
platform = NestedPlatformSerializer(required=False, allow_null=True)
|
||||
@@ -77,9 +81,9 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = VirtualMachine
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip',
|
||||
'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
|
||||
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data',
|
||||
'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
validators = []
|
||||
|
||||
@@ -89,9 +93,9 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
|
||||
|
||||
class Meta(VirtualMachineSerializer.Meta):
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip',
|
||||
'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags',
|
||||
'custom_fields', 'config_context', 'created', 'last_updated',
|
||||
'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
|
||||
'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data',
|
||||
'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
|
@@ -54,7 +54,7 @@ class ClusterViewSet(NetBoxModelViewSet):
|
||||
|
||||
class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
|
||||
queryset = VirtualMachine.objects.prefetch_related(
|
||||
'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
|
||||
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
|
||||
)
|
||||
filterset_class = filtersets.VirtualMachineFilterSet
|
||||
|
||||
|
@@ -1,6 +1,28 @@
|
||||
from utilities.choices import ChoiceSet
|
||||
|
||||
|
||||
#
|
||||
# Clusters
|
||||
#
|
||||
|
||||
class ClusterStatusChoices(ChoiceSet):
|
||||
key = 'Cluster.status'
|
||||
|
||||
STATUS_PLANNED = 'planned'
|
||||
STATUS_STAGING = 'staging'
|
||||
STATUS_ACTIVE = 'active'
|
||||
STATUS_DECOMMISSIONING = 'decommissioning'
|
||||
STATUS_OFFLINE = 'offline'
|
||||
|
||||
CHOICES = [
|
||||
(STATUS_PLANNED, 'Planned', 'cyan'),
|
||||
(STATUS_STAGING, 'Staging', 'blue'),
|
||||
(STATUS_ACTIVE, 'Active', 'green'),
|
||||
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
|
||||
(STATUS_OFFLINE, 'Offline', 'red'),
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# VirtualMachines
|
||||
#
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import django_filters
|
||||
from django.db.models import Q
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
|
||||
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
from ipam.models import VRF
|
||||
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
|
||||
@@ -90,6 +90,10 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
|
||||
to_field_name='slug',
|
||||
label='Cluster type (slug)',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=ClusterStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Cluster
|
||||
@@ -146,39 +150,48 @@ class VirtualMachineFilterSet(
|
||||
to_field_name='name',
|
||||
label='Cluster',
|
||||
)
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
label='Device (ID)',
|
||||
)
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__name',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Device',
|
||||
)
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='cluster__site__region',
|
||||
field_name='site__region',
|
||||
lookup_expr='in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='cluster__site__region',
|
||||
field_name='site__region',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
site_group_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
field_name='cluster__site__group',
|
||||
field_name='site__group',
|
||||
lookup_expr='in',
|
||||
label='Site group (ID)',
|
||||
)
|
||||
site_group = TreeNodeMultipleChoiceFilter(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
field_name='cluster__site__group',
|
||||
field_name='site__group',
|
||||
lookup_expr='in',
|
||||
to_field_name='slug',
|
||||
label='Site group (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='cluster__site',
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='cluster__site__slug',
|
||||
field_name='site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site (slug)',
|
||||
|
@@ -2,7 +2,7 @@ from django import forms
|
||||
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
|
||||
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
|
||||
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
||||
from ipam.models import VLAN, VRF
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
@@ -58,6 +58,12 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False
|
||||
)
|
||||
status = forms.ChoiceField(
|
||||
choices=add_blank_choice(ClusterStatusChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect()
|
||||
)
|
||||
tenant = DynamicModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False
|
||||
@@ -85,7 +91,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = Cluster
|
||||
fieldsets = (
|
||||
(None, ('type', 'group', 'tenant',)),
|
||||
(None, ('type', 'group', 'status', 'tenant',)),
|
||||
('Site', ('region', 'site_group', 'site',)),
|
||||
)
|
||||
nullable_fields = (
|
||||
@@ -100,9 +106,23 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
|
||||
initial='',
|
||||
widget=StaticSelect(),
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'cluster_id': '$cluster'
|
||||
}
|
||||
)
|
||||
role = DynamicModelChoiceField(
|
||||
queryset=DeviceRole.objects.filter(
|
||||
@@ -140,11 +160,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = VirtualMachine
|
||||
fieldsets = (
|
||||
(None, ('cluster', 'status', 'role', 'tenant', 'platform')),
|
||||
(None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform')),
|
||||
('Resources', ('vcpus', 'memory', 'disk'))
|
||||
)
|
||||
nullable_fields = (
|
||||
'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
|
||||
'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
|
||||
)
|
||||
|
||||
|
||||
@@ -223,8 +243,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
# See 5643
|
||||
if 'pk' in self.initial:
|
||||
site = None
|
||||
interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related(
|
||||
'virtual_machine__cluster__site'
|
||||
interfaces = VMInterface.objects.filter(
|
||||
pk__in=self.initial['pk']
|
||||
).prefetch_related(
|
||||
'virtual_machine__site'
|
||||
)
|
||||
|
||||
# Check interface sites. First interface should set site, further interfaces will either continue the
|
||||
|
@@ -1,5 +1,5 @@
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from dcim.models import DeviceRole, Platform, Site
|
||||
from dcim.models import Device, DeviceRole, Platform, Site
|
||||
from ipam.models import VRF
|
||||
from netbox.forms import NetBoxModelCSVForm
|
||||
from tenancy.models import Tenant
|
||||
@@ -44,6 +44,10 @@ class ClusterCSVForm(NetBoxModelCSVForm):
|
||||
required=False,
|
||||
help_text='Assigned cluster group'
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
choices=ClusterStatusChoices,
|
||||
help_text='Operational status'
|
||||
)
|
||||
site = CSVModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='name',
|
||||
@@ -59,7 +63,7 @@ class ClusterCSVForm(NetBoxModelCSVForm):
|
||||
|
||||
class Meta:
|
||||
model = Cluster
|
||||
fields = ('name', 'type', 'group', 'site', 'comments')
|
||||
fields = ('name', 'type', 'group', 'status', 'site', 'comments')
|
||||
|
||||
|
||||
class VirtualMachineCSVForm(NetBoxModelCSVForm):
|
||||
@@ -67,11 +71,24 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
|
||||
choices=VirtualMachineStatusChoices,
|
||||
help_text='Operational status'
|
||||
)
|
||||
site = CSVModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False,
|
||||
help_text='Assigned site'
|
||||
)
|
||||
cluster = CSVModelChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False,
|
||||
help_text='Assigned cluster'
|
||||
)
|
||||
device = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False,
|
||||
help_text='Assigned device within cluster'
|
||||
)
|
||||
role = CSVModelChoiceField(
|
||||
queryset=DeviceRole.objects.filter(
|
||||
vm_role=True
|
||||
@@ -96,7 +113,8 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
|
||||
class Meta:
|
||||
model = VirtualMachine
|
||||
fields = (
|
||||
'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
|
||||
'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
|
||||
'comments',
|
||||
)
|
||||
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
|
||||
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
||||
from extras.forms import LocalConfigContextFilterForm
|
||||
from ipam.models import VRF
|
||||
from netbox.forms import NetBoxModelFilterSetForm
|
||||
@@ -35,7 +35,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
model = Cluster
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Attributes', ('group_id', 'type_id')),
|
||||
('Attributes', ('group_id', 'type_id', 'status')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role')),
|
||||
@@ -50,6 +50,10 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
required=False,
|
||||
label=_('Region')
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
choices=ClusterStatusChoices,
|
||||
required=False
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
@@ -83,7 +87,7 @@ class VirtualMachineFilterForm(
|
||||
model = VirtualMachine
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id')),
|
||||
('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id')),
|
||||
('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
@@ -106,6 +110,11 @@ class VirtualMachineFilterForm(
|
||||
required=False,
|
||||
label=_('Cluster')
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
label=_('Device')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
|
@@ -79,15 +79,19 @@ class ClusterForm(TenancyForm, NetBoxModelForm):
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')),
|
||||
('Cluster', ('name', 'type', 'group', 'status', 'tags')),
|
||||
('Site', ('region', 'site_group', 'site')),
|
||||
('Tenancy', ('tenant_group', 'tenant')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Cluster
|
||||
fields = (
|
||||
'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags',
|
||||
'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags',
|
||||
)
|
||||
widgets = {
|
||||
'status': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
|
||||
@@ -161,6 +165,9 @@ class ClusterRemoveDevicesForm(ConfirmationForm):
|
||||
|
||||
|
||||
class VirtualMachineForm(TenancyForm, NetBoxModelForm):
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all()
|
||||
)
|
||||
cluster_group = DynamicModelChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False,
|
||||
@@ -172,7 +179,15 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
|
||||
cluster = DynamicModelChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
query_params={
|
||||
'group_id': '$cluster_group'
|
||||
'site_id': '$site',
|
||||
'group_id': '$cluster_group',
|
||||
}
|
||||
)
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'cluster_id': '$cluster'
|
||||
}
|
||||
)
|
||||
role = DynamicModelChoiceField(
|
||||
@@ -193,7 +208,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
|
||||
|
||||
fieldsets = (
|
||||
('Virtual Machine', ('name', 'role', 'status', 'tags')),
|
||||
('Cluster', ('cluster_group', 'cluster')),
|
||||
('Cluster', ('site', 'cluster_group', 'cluster', 'device')),
|
||||
('Tenancy', ('tenant_group', 'tenant')),
|
||||
('Management', ('platform', 'primary_ip4', 'primary_ip6')),
|
||||
('Resources', ('vcpus', 'memory', 'disk')),
|
||||
@@ -203,8 +218,9 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
|
||||
class Meta:
|
||||
model = VirtualMachine
|
||||
fields = [
|
||||
'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4',
|
||||
'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data',
|
||||
'name', 'status', 'site', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant',
|
||||
'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags',
|
||||
'local_context_data',
|
||||
]
|
||||
help_texts = {
|
||||
'local_context_data': "Local config context data overwrites all sources contexts in the final rendered "
|
||||
|
18
netbox/virtualization/migrations/0030_cluster_status.py
Normal file
18
netbox/virtualization/migrations/0030_cluster_status.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.0.4 on 2022-05-19 19:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('virtualization', '0029_created_datetimefield'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cluster',
|
||||
name='status',
|
||||
field=models.CharField(default='active', max_length=50),
|
||||
),
|
||||
]
|
@@ -0,0 +1,28 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0153_created_datetimefield'),
|
||||
('virtualization', '0030_cluster_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='virtualmachine',
|
||||
name='site',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.site'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='virtualmachine',
|
||||
name='device',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.device'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='virtualmachine',
|
||||
name='cluster',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='virtualization.cluster'),
|
||||
),
|
||||
]
|
@@ -0,0 +1,27 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_virtualmachines_site(apps, schema_editor):
|
||||
"""
|
||||
Automatically set the site for all virtual machines.
|
||||
"""
|
||||
VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
|
||||
|
||||
virtual_machines = VirtualMachine.objects.filter(cluster__site__isnull=False)
|
||||
for vm in virtual_machines:
|
||||
vm.site = vm.cluster.site
|
||||
VirtualMachine.objects.bulk_update(virtual_machines, ['site'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('virtualization', '0031_virtualmachine_site_device'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=update_virtualmachines_site,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
@@ -119,6 +119,11 @@ class Cluster(NetBoxModel):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=ClusterStatusChoices,
|
||||
default=ClusterStatusChoices.STATUS_ACTIVE
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
on_delete=models.PROTECT,
|
||||
@@ -165,6 +170,9 @@ class Cluster(NetBoxModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('virtualization:cluster', args=[self.pk])
|
||||
|
||||
def get_status_color(self):
|
||||
return ClusterStatusChoices.colors.get(self.status)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
@@ -187,10 +195,26 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
|
||||
"""
|
||||
A virtual machine which runs inside a Cluster.
|
||||
"""
|
||||
site = models.ForeignKey(
|
||||
to='dcim.Site',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='virtual_machines',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
cluster = models.ForeignKey(
|
||||
to='virtualization.Cluster',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='virtual_machines'
|
||||
related_name='virtual_machines',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
device = models.ForeignKey(
|
||||
to='dcim.Device',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='virtual_machines',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
@@ -276,7 +300,7 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
|
||||
objects = ConfigContextModelQuerySet.as_manager()
|
||||
|
||||
clone_fields = [
|
||||
'cluster', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
|
||||
'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
@@ -308,6 +332,28 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Must be assigned to a site and/or cluster
|
||||
if not self.site and not self.cluster:
|
||||
raise ValidationError({
|
||||
'cluster': f'A virtual machine must be assigned to a site and/or cluster.'
|
||||
})
|
||||
|
||||
# Validate site for cluster & device
|
||||
if self.cluster and self.cluster.site != self.site:
|
||||
raise ValidationError({
|
||||
'cluster': f'The selected cluster ({self.cluster} is not assigned to this site ({self.site}).'
|
||||
})
|
||||
if self.device and self.device.site != self.site:
|
||||
raise ValidationError({
|
||||
'device': f'The selected device ({self.device} is not assigned to this site ({self.site}).'
|
||||
})
|
||||
|
||||
# Validate assigned cluster device
|
||||
if self.device and self.device not in self.cluster.devices.all():
|
||||
raise ValidationError({
|
||||
'device': f'The selected device ({self.device} is not assigned to this cluster ({self.cluster}).'
|
||||
})
|
||||
|
||||
# Validate primary IP addresses
|
||||
interfaces = self.interfaces.all()
|
||||
for field in ['primary_ip4', 'primary_ip6']:
|
||||
@@ -336,10 +382,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def site(self):
|
||||
return self.cluster.site
|
||||
|
||||
|
||||
#
|
||||
# Interfaces
|
||||
|
@@ -66,6 +66,7 @@ class ClusterTable(NetBoxTable):
|
||||
group = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
tenant = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -93,7 +94,7 @@ class ClusterTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Cluster
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'contacts',
|
||||
'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'device_count', 'vm_count',
|
||||
'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count')
|
||||
default_columns = ('pk', 'name', 'type', 'group', 'status', 'tenant', 'site', 'device_count', 'vm_count')
|
||||
|
@@ -30,9 +30,15 @@ class VirtualMachineTable(NetBoxTable):
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
cluster = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
role = columns.ColoredLabelColumn()
|
||||
tenant = TenantColumn()
|
||||
comments = columns.MarkdownColumn()
|
||||
@@ -56,11 +62,11 @@ class VirtualMachineTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = VirtualMachine
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
|
||||
'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory',
|
||||
'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
|
||||
'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
|
||||
)
|
||||
|
||||
|
||||
|
@@ -2,8 +2,10 @@ from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from dcim.models import Site
|
||||
from ipam.models import VLAN, VRF
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
|
||||
from virtualization.choices import *
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
|
||||
|
||||
@@ -85,6 +87,7 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Cluster
|
||||
brief_fields = ['display', 'id', 'name', 'url', 'virtualmachine_count']
|
||||
bulk_update_data = {
|
||||
'status': 'offline',
|
||||
'comments': 'New comment',
|
||||
}
|
||||
|
||||
@@ -104,9 +107,9 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
|
||||
ClusterGroup.objects.bulk_create(cluster_groups)
|
||||
|
||||
clusters = (
|
||||
Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0]),
|
||||
Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0]),
|
||||
Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0]),
|
||||
Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED),
|
||||
Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED),
|
||||
Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED),
|
||||
)
|
||||
Cluster.objects.bulk_create(clusters)
|
||||
|
||||
@@ -115,16 +118,19 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
|
||||
'name': 'Cluster 4',
|
||||
'type': cluster_types[1].pk,
|
||||
'group': cluster_groups[1].pk,
|
||||
'status': ClusterStatusChoices.STATUS_STAGING,
|
||||
},
|
||||
{
|
||||
'name': 'Cluster 5',
|
||||
'type': cluster_types[1].pk,
|
||||
'group': cluster_groups[1].pk,
|
||||
'status': ClusterStatusChoices.STATUS_STAGING,
|
||||
},
|
||||
{
|
||||
'name': 'Cluster 6',
|
||||
'type': cluster_types[1].pk,
|
||||
'group': cluster_groups[1].pk,
|
||||
'status': ClusterStatusChoices.STATUS_STAGING,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -141,31 +147,49 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
|
||||
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 = (
|
||||
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)
|
||||
|
||||
clusters = (
|
||||
Cluster(name='Cluster 1', type=clustertype, group=clustergroup),
|
||||
Cluster(name='Cluster 2', type=clustertype, group=clustergroup),
|
||||
Cluster(name='Cluster 1', type=clustertype, site=sites[0], group=clustergroup),
|
||||
Cluster(name='Cluster 2', type=clustertype, site=sites[1], group=clustergroup),
|
||||
Cluster(name='Cluster 3', type=clustertype),
|
||||
)
|
||||
Cluster.objects.bulk_create(clusters)
|
||||
|
||||
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', cluster=clusters[0], local_context_data={'A': 1}),
|
||||
VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], local_context_data={'B': 2}),
|
||||
VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], local_context_data={'C': 3}),
|
||||
VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=device1, local_context_data={'A': 1}),
|
||||
VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], local_context_data={'B': 2}),
|
||||
VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], local_context_data={'C': 3}),
|
||||
)
|
||||
VirtualMachine.objects.bulk_create(virtual_machines)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'name': 'Virtual Machine 4',
|
||||
'site': sites[1].pk,
|
||||
'cluster': clusters[1].pk,
|
||||
'device': device2.pk,
|
||||
},
|
||||
{
|
||||
'name': 'Virtual Machine 5',
|
||||
'site': sites[1].pk,
|
||||
'cluster': clusters[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Virtual Machine 6',
|
||||
'cluster': clusters[1].pk,
|
||||
'site': sites[1].pk,
|
||||
},
|
||||
{
|
||||
'name': 'Virtual Machine 7',
|
||||
'cluster': clusters[2].pk,
|
||||
},
|
||||
]
|
||||
|
||||
|
@@ -1,9 +1,9 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
|
||||
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
||||
from ipam.models import IPAddress, VRF
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.testing import ChangeLoggedFilterSetTests
|
||||
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
|
||||
from virtualization.choices import *
|
||||
from virtualization.filtersets import *
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
@@ -123,9 +123,9 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
clusters = (
|
||||
Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], site=sites[0], tenant=tenants[0]),
|
||||
Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], site=sites[1], tenant=tenants[1]),
|
||||
Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], site=sites[2], tenant=tenants[2]),
|
||||
Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED, site=sites[0], tenant=tenants[0]),
|
||||
Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], status=ClusterStatusChoices.STATUS_STAGING, site=sites[1], tenant=tenants[1]),
|
||||
Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[2], tenant=tenants[2]),
|
||||
)
|
||||
Cluster.objects.bulk_create(clusters)
|
||||
|
||||
@@ -161,6 +161,10 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'group': [groups[0].slug, groups[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_status(self):
|
||||
params = {'status': [ClusterStatusChoices.STATUS_PLANNED, ClusterStatusChoices.STATUS_STAGING]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_type(self):
|
||||
types = ClusterType.objects.all()[:2]
|
||||
params = {'type_id': [types[0].pk, types[1].pk]}
|
||||
@@ -221,9 +225,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
site_group.save()
|
||||
|
||||
sites = (
|
||||
Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
|
||||
Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
|
||||
Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]),
|
||||
Site(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]),
|
||||
Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]),
|
||||
Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
@@ -248,6 +252,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
DeviceRole.objects.bulk_create(roles)
|
||||
|
||||
devices = (
|
||||
create_test_device('device1', cluster=clusters[0]),
|
||||
create_test_device('device2', cluster=clusters[1]),
|
||||
create_test_device('device3', cluster=clusters[2]),
|
||||
)
|
||||
|
||||
tenant_groups = (
|
||||
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
|
||||
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
|
||||
@@ -264,9 +274,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
vms = (
|
||||
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}),
|
||||
VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2),
|
||||
VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
|
||||
VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}),
|
||||
VirtualMachine(name='Virtual Machine 2', site=sites[1], cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2),
|
||||
VirtualMachine(name='Virtual Machine 3', site=sites[2], cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
|
||||
)
|
||||
VirtualMachine.objects.bulk_create(vms)
|
||||
|
||||
@@ -327,6 +337,13 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'cluster': [clusters[0].name, clusters[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_device(self):
|
||||
devices = Device.objects.all()[:2]
|
||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'device': [devices[0].name, devices[1].name]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_region(self):
|
||||
regions = Region.objects.all()[:2]
|
||||
params = {'region_id': [regions[0].pk, regions[1].pk]}
|
||||
|
@@ -1,21 +1,19 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import Site
|
||||
from virtualization.models import *
|
||||
from tenancy.models import Tenant
|
||||
|
||||
|
||||
class VirtualMachineTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='Test Cluster Type 1')
|
||||
self.cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type)
|
||||
|
||||
def test_vm_duplicate_name_per_cluster(self):
|
||||
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||
cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type)
|
||||
|
||||
vm1 = VirtualMachine(
|
||||
cluster=self.cluster,
|
||||
cluster=cluster,
|
||||
name='Test VM 1'
|
||||
)
|
||||
vm1.save()
|
||||
@@ -43,3 +41,33 @@ class VirtualMachineTestCase(TestCase):
|
||||
# Two VMs assigned to the same Cluster and different Tenants should pass validation
|
||||
vm2.full_clean()
|
||||
vm2.save()
|
||||
|
||||
def test_vm_mismatched_site_cluster(self):
|
||||
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
clusters = (
|
||||
Cluster(name='Cluster 1', type=cluster_type, site=sites[0]),
|
||||
Cluster(name='Cluster 2', type=cluster_type, site=sites[1]),
|
||||
Cluster(name='Cluster 3', type=cluster_type, site=None),
|
||||
)
|
||||
Cluster.objects.bulk_create(clusters)
|
||||
|
||||
# VM with site only should pass
|
||||
VirtualMachine(name='vm1', site=sites[0]).full_clean()
|
||||
|
||||
# VM with non-site cluster only should pass
|
||||
VirtualMachine(name='vm1', cluster=clusters[2]).full_clean()
|
||||
|
||||
# VM with mismatched site & cluster should fail
|
||||
with self.assertRaises(ValidationError):
|
||||
VirtualMachine(name='vm1', site=sites[0], cluster=clusters[1]).full_clean()
|
||||
|
||||
# VM with cluster site but no direct site should fail
|
||||
with self.assertRaises(ValidationError):
|
||||
VirtualMachine(name='vm1', site=None, cluster=clusters[0]).full_clean()
|
||||
|
@@ -5,7 +5,7 @@ from netaddr import EUI
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from dcim.models import DeviceRole, Platform, Site
|
||||
from ipam.models import VLAN, VRF
|
||||
from utilities.testing import ViewTestCases, create_tags
|
||||
from utilities.testing import ViewTestCases, create_tags, create_test_device
|
||||
from virtualization.choices import *
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
|
||||
@@ -101,9 +101,9 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
ClusterType.objects.bulk_create(clustertypes)
|
||||
|
||||
Cluster.objects.bulk_create([
|
||||
Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
|
||||
Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
|
||||
Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
|
||||
Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]),
|
||||
Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]),
|
||||
Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]),
|
||||
])
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
@@ -112,6 +112,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'name': 'Cluster X',
|
||||
'group': clustergroups[1].pk,
|
||||
'type': clustertypes[1].pk,
|
||||
'status': ClusterStatusChoices.STATUS_OFFLINE,
|
||||
'tenant': None,
|
||||
'site': sites[1].pk,
|
||||
'comments': 'Some comments',
|
||||
@@ -119,15 +120,16 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,type",
|
||||
"Cluster 4,Cluster Type 1",
|
||||
"Cluster 5,Cluster Type 1",
|
||||
"Cluster 6,Cluster Type 1",
|
||||
"name,type,status",
|
||||
"Cluster 4,Cluster Type 1,active",
|
||||
"Cluster 5,Cluster Type 1,active",
|
||||
"Cluster 6,Cluster Type 1,active",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'group': clustergroups[1].pk,
|
||||
'type': clustertypes[1].pk,
|
||||
'status': ClusterStatusChoices.STATUS_OFFLINE,
|
||||
'tenant': None,
|
||||
'site': sites[1].pk,
|
||||
'comments': 'New comments',
|
||||
@@ -166,24 +168,37 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
)
|
||||
Platform.objects.bulk_create(platforms)
|
||||
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||
|
||||
clusters = (
|
||||
Cluster(name='Cluster 1', type=clustertype),
|
||||
Cluster(name='Cluster 2', type=clustertype),
|
||||
Cluster(name='Cluster 1', type=clustertype, site=sites[0]),
|
||||
Cluster(name='Cluster 2', type=clustertype, site=sites[1]),
|
||||
)
|
||||
Cluster.objects.bulk_create(clusters)
|
||||
|
||||
devices = (
|
||||
create_test_device('device1', site=sites[0], cluster=clusters[0]),
|
||||
create_test_device('device2', site=sites[1], cluster=clusters[1]),
|
||||
)
|
||||
|
||||
VirtualMachine.objects.bulk_create([
|
||||
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
|
||||
VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
|
||||
VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
|
||||
VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
|
||||
VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
|
||||
VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
|
||||
])
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'cluster': clusters[1].pk,
|
||||
'device': devices[1].pk,
|
||||
'site': sites[1].pk,
|
||||
'tenant': None,
|
||||
'platform': platforms[1].pk,
|
||||
'name': 'Virtual Machine X',
|
||||
@@ -200,14 +215,16 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,status,cluster",
|
||||
"Virtual Machine 4,active,Cluster 1",
|
||||
"Virtual Machine 5,active,Cluster 1",
|
||||
"Virtual Machine 6,active,Cluster 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.bulk_edit_data = {
|
||||
'site': sites[1].pk,
|
||||
'cluster': clusters[1].pk,
|
||||
'device': devices[1].pk,
|
||||
'tenant': None,
|
||||
'platform': platforms[1].pk,
|
||||
'status': VirtualMachineStatusChoices.STATUS_STAGED,
|
||||
@@ -243,8 +260,8 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||
cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site)
|
||||
virtualmachines = (
|
||||
VirtualMachine(name='Virtual Machine 1', cluster=cluster, role=devicerole),
|
||||
VirtualMachine(name='Virtual Machine 2', cluster=cluster, role=devicerole),
|
||||
VirtualMachine(name='Virtual Machine 1', site=site, cluster=cluster, role=devicerole),
|
||||
VirtualMachine(name='Virtual Machine 2', site=site, cluster=cluster, role=devicerole),
|
||||
)
|
||||
VirtualMachine.objects.bulk_create(virtualmachines)
|
||||
|
||||
|
Reference in New Issue
Block a user