فهرست منبع

Merge branch 'feature' into 9102-cabling

jeremystretch 3 سال پیش
والد
کامیت
bd950e9ca6
38فایلهای تغییر یافته به همراه541 افزوده شده و 128 حذف شده
  1. 1 1
      docs/models/virtualization/cluster.md
  2. 1 1
      docs/models/virtualization/virtualmachine.md
  3. 11 1
      docs/release-notes/version-3.3.md
  4. 3 2
      netbox/extras/api/serializers.py
  5. 13 0
      netbox/extras/choices.py
  6. 4 1
      netbox/extras/filtersets.py
  7. 7 0
      netbox/extras/forms/bulk_edit.py
  8. 1 0
      netbox/extras/forms/bulk_import.py
  9. 11 0
      netbox/extras/forms/customfields.py
  10. 7 1
      netbox/extras/forms/filtersets.py
  11. 2 1
      netbox/extras/forms/models.py
  12. 18 0
      netbox/extras/migrations/0075_customfield_ui_visibility.py
  13. 7 0
      netbox/extras/models/customfields.py
  14. 2 1
      netbox/extras/tables/tables.py
  15. 5 4
      netbox/extras/tests/test_views.py
  16. 8 4
      netbox/netbox/models/features.py
  17. 5 1
      netbox/netbox/tables/tables.py
  18. 8 4
      netbox/templates/extras/customfield.html
  19. 15 7
      netbox/templates/virtualization/virtualmachine.html
  20. 4 3
      netbox/utilities/testing/utils.py
  21. 15 11
      netbox/virtualization/api/serializers.py
  22. 1 1
      netbox/virtualization/api/views.py
  23. 22 0
      netbox/virtualization/choices.py
  24. 20 7
      netbox/virtualization/filtersets.py
  25. 29 7
      netbox/virtualization/forms/bulk_edit.py
  26. 21 3
      netbox/virtualization/forms/bulk_import.py
  27. 12 3
      netbox/virtualization/forms/filtersets.py
  28. 22 6
      netbox/virtualization/forms/models.py
  29. 18 0
      netbox/virtualization/migrations/0030_cluster_status.py
  30. 28 0
      netbox/virtualization/migrations/0031_virtualmachine_site_device.py
  31. 27 0
      netbox/virtualization/migrations/0032_virtualmachine_update_sites.py
  32. 48 6
      netbox/virtualization/models.py
  33. 4 3
      netbox/virtualization/tables/clusters.py
  34. 9 3
      netbox/virtualization/tables/virtualmachines.py
  35. 34 10
      netbox/virtualization/tests/test_api.py
  36. 28 11
      netbox/virtualization/tests/test_filtersets.py
  37. 34 6
      netbox/virtualization/tests/test_models.py
  38. 36 19
      netbox/virtualization/tests/test_views.py

+ 1 - 1
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.

+ 1 - 1
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:
 

+ 11 - 1
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.

+ 3 - 2
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):

+ 13 - 0
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
 #

+ 4 - 1
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():

+ 7 - 0
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',)
 

+ 1 - 0
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',
         )
 
 

+ 11 - 0
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 += '<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

+ 7 - 1
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):

+ 2 - 1
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(),
         }
 
 

+ 18 - 0
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),
+        ),
+    ]

+ 7 - 0
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:

+ 2 - 1
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')
 

+ 5 - 4
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 = {

+ 8 - 4
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)

+ 5 - 1
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
         ])

+ 8 - 4
netbox/templates/extras/customfield.html

@@ -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>

+ 15 - 7
netbox/templates/virtualization/virtualmachine.html

@@ -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>

+ 4 - 3
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
 

+ 15 - 11
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)

+ 1 - 1
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
 

+ 22 - 0
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
 #

+ 20 - 7
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)',

+ 29 - 7
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

+ 21 - 3
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',
         )
 
 

+ 12 - 3
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,

+ 22 - 6
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 "

+ 18 - 0
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),
+        ),
+    ]

+ 28 - 0
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'),
+        ),
+    ]

+ 27 - 0
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
+        ),
+    ]

+ 48 - 6
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

+ 4 - 3
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')

+ 9 - 3
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',
         )
 
 

+ 34 - 10
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,
             },
         ]
 

+ 28 - 11
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]}

+ 34 - 6
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()

+ 36 - 19
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)