diff --git a/docs/models/virtualization/cluster.md b/docs/models/virtualization/cluster.md
index 7fc9bfc06..3e3516cd6 100644
--- a/docs/models/virtualization/cluster.md
+++ b/docs/models/virtualization/cluster.md
@@ -1,5 +1,5 @@
# Clusters
-A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any.
+A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification) and operational status, and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any.
Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device.
diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md
index de9b5f214..4ddffb99a 100644
--- a/docs/models/virtualization/virtualmachine.md
+++ b/docs/models/virtualization/virtualmachine.md
@@ -1,6 +1,6 @@
# Virtual Machines
-A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster.
+A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to a site and/or cluster, and may optionally be assigned to a particular host device within a cluster.
Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it:
diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md
index c46fceea5..63fd9731f 100644
--- a/docs/release-notes/version-3.3.md
+++ b/docs/release-notes/version-3.3.md
@@ -9,8 +9,12 @@
### Enhancements
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
+* [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster
+* [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster
+* [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster
* [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping
* [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results
+* [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields
### Other Changes
@@ -19,7 +23,13 @@
### REST API Changes
* extras.CustomField
- * Added `group_name` field
+ * Added `group_name` and `ui_visibility` fields
* ipam.IPAddress
* The `nat_inside` field no longer requires a unique value
* The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses
+* virtualization.Cluster
+ * Added required `status` field (default value: `active`)
+* virtualization.VirtualMachine
+ * Added `device` field
+ * The `site` field is now directly writable (rather than being inferred from the assigned cluster)
+ * The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py
index eed7f7603..cb317d6c7 100644
--- a/netbox/extras/api/serializers.py
+++ b/netbox/extras/api/serializers.py
@@ -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):
diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py
index f14368d3d..123fd2cd4 100644
--- a/netbox/extras/choices.py
+++ b/netbox/extras/choices.py
@@ -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
#
diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py
index 467ae23af..b59e28018 100644
--- a/netbox/extras/filtersets.py
+++ b/netbox/extras/filtersets.py
@@ -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():
diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py
index b722bd751..b1d8a6c21 100644
--- a/netbox/extras/forms/bulk_edit.py
+++ b/netbox/extras/forms/bulk_edit.py
@@ -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',)
diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py
index dabf2f811..95de7a2fe 100644
--- a/netbox/extras/forms/bulk_import.py
+++ b/netbox/extras/forms/bulk_import.py
@@ -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',
)
diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py
index bb8028eec..4cf8b5e0a 100644
--- a/netbox/extras/forms/customfields.py
+++ b/netbox/extras/forms/customfields.py
@@ -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 += '
'
+ self.fields[field_name].help_text += ' ' \
+ 'Field is set to read-only.'
+
# Annotate the field in the list of CustomField form fields
self.custom_fields[field_name] = customfield
diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py
index 1710ecb89..aaeb45dbe 100644
--- a/netbox/extras/forms/filtersets.py
+++ b/netbox/extras/forms/filtersets.py
@@ -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):
diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py
index b07853f86..ab423e2fb 100644
--- a/netbox/extras/forms/models.py
+++ b/netbox/extras/forms/models.py
@@ -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(),
}
diff --git a/netbox/extras/migrations/0075_customfield_ui_visibility.py b/netbox/extras/migrations/0075_customfield_ui_visibility.py
new file mode 100644
index 000000000..29ee65516
--- /dev/null
+++ b/netbox/extras/migrations/0075_customfield_ui_visibility.py
@@ -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),
+ ),
+ ]
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index 55caa4a70..c91f96c15 100644
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -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:
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index 1a0f5d58a..540034696 100644
--- a/netbox/extras/tables/tables.py
+++ b/netbox/extras/tables/tables.py
@@ -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')
diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py
index ea3a952d6..0a9d85e15 100644
--- a/netbox/extras/tests/test_views.py
+++ b/netbox/extras/tests/test_views.py
@@ -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 = {
diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py
index 4bd1b0e9c..817da526b 100644
--- a/netbox/netbox/models/features.py
+++ b/netbox/netbox/models/features.py
@@ -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)
diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py
index 5ebb78865..38399b5fe 100644
--- a/netbox/netbox/tables/tables.py
+++ b/netbox/netbox/tables/tables.py
@@ -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
])
diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html
index dc51d3e82..aca0b5012 100644
--- a/netbox/templates/extras/customfield.html
+++ b/netbox/templates/extras/customfield.html
@@ -42,6 +42,14 @@
Weight |
{{ object.weight }} |
+
+ Filter Logic |
+ {{ object.get_filter_logic_display }} |
+
+
+ UI Visibility |
+ {{ object.get_ui_visibility_display }} |
+
@@ -65,10 +73,6 @@
{% endif %}
-
- Filter Logic |
- {{ object.get_filter_logic_display }} |
-
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html
index 0dec4968c..2831a452a 100644
--- a/netbox/templates/virtualization/virtualmachine.html
+++ b/netbox/templates/virtualization/virtualmachine.html
@@ -78,31 +78,39 @@
-
+
+
+ Site |
+
+ {{ object.site|linkify|placeholder }}
+ |
+
Cluster |
{% if object.cluster.group %}
{{ object.cluster.group|linkify }} /
{% endif %}
- {{ object.cluster|linkify }}
+ {{ object.cluster|linkify|placeholder }}
|
Cluster Type |
{{ object.cluster.type }} |
+
+ Device |
+
+ {{ object.device|linkify|placeholder }}
+ |
+
-
+
diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py
index 466b5e22b..52ccd002d 100644
--- a/netbox/utilities/testing/utils.py
+++ b/netbox/utilities/testing/utils.py
@@ -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
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index afdf50b96..bd01b5533 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -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)
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index 665114881..d2a90ae34 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -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
diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py
index 693e53df6..2cf6357e1 100644
--- a/netbox/virtualization/choices.py
+++ b/netbox/virtualization/choices.py
@@ -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
#
diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py
index 5a2aa8b42..00d3e2313 100644
--- a/netbox/virtualization/filtersets.py
+++ b/netbox/virtualization/filtersets.py
@@ -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)',
diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py
index d5d33df2a..88dee3978 100644
--- a/netbox/virtualization/forms/bulk_edit.py
+++ b/netbox/virtualization/forms/bulk_edit.py
@@ -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
diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py
index eab6fc9e7..2d7ee52e2 100644
--- a/netbox/virtualization/forms/bulk_import.py
+++ b/netbox/virtualization/forms/bulk_import.py
@@ -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',
)
diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py
index 2f386e889..b3da87f7a 100644
--- a/netbox/virtualization/forms/filtersets.py
+++ b/netbox/virtualization/forms/filtersets.py
@@ -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,
diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py
index 314b0bddf..cfafd7e39 100644
--- a/netbox/virtualization/forms/models.py
+++ b/netbox/virtualization/forms/models.py
@@ -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 "
diff --git a/netbox/virtualization/migrations/0030_cluster_status.py b/netbox/virtualization/migrations/0030_cluster_status.py
new file mode 100644
index 000000000..e836bb914
--- /dev/null
+++ b/netbox/virtualization/migrations/0030_cluster_status.py
@@ -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),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0031_virtualmachine_site_device.py b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py
new file mode 100644
index 000000000..85ea24455
--- /dev/null
+++ b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py
@@ -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'),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py
new file mode 100644
index 000000000..e9c52bfde
--- /dev/null
+++ b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py
@@ -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
+ ),
+ ]
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 586bb8a9e..02560a962 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -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
diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py
index a0c98425a..dfcae052a 100644
--- a/netbox/virtualization/tables/clusters.py
+++ b/netbox/virtualization/tables/clusters.py
@@ -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')
diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py
index 89dbdf901..0fe2571b1 100644
--- a/netbox/virtualization/tables/virtualmachines.py
+++ b/netbox/virtualization/tables/virtualmachines.py
@@ -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',
)
diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py
index f6c07fa54..b2ae68860 100644
--- a/netbox/virtualization/tests/test_api.py
+++ b/netbox/virtualization/tests/test_api.py
@@ -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,
},
]
diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py
index 9e264ac5c..d3ff12887 100644
--- a/netbox/virtualization/tests/test_filtersets.py
+++ b/netbox/virtualization/tests/test_filtersets.py
@@ -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]}
diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py
index 3b4d73a30..df5816efa 100644
--- a/netbox/virtualization/tests/test_models.py
+++ b/netbox/virtualization/tests/test_models.py
@@ -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()
diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py
index 8edc14f00..01d4394f3 100644
--- a/netbox/virtualization/tests/test_views.py
+++ b/netbox/virtualization/tests/test_views.py
@@ -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)