Просмотр исходного кода

Merge branch 'feature' into 9102-cabling

jeremystretch 3 лет назад
Родитель
Сommit
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
 # 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.
 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
 # 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:
 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
 ### Enhancements
 
 
 * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
 * [#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
 * [#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
 * [#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
 ### Other Changes
 
 
@@ -19,7 +23,13 @@
 ### REST API Changes
 ### REST API Changes
 
 
 * extras.CustomField
 * extras.CustomField
-    * Added `group_name` field
+    * Added `group_name` and `ui_visibility` fields
 * ipam.IPAddress
 * ipam.IPAddress
     * The `nat_inside` field no longer requires a unique value
     * 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
     * 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)
     filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
     data_type = serializers.SerializerMethodField()
     data_type = serializers.SerializerMethodField()
+    ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False)
 
 
     class Meta:
     class Meta:
         model = CustomField
         model = CustomField
         fields = [
         fields = [
             'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
             '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):
     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
 # CustomLinks
 #
 #

+ 4 - 1
netbox/extras/filtersets.py

@@ -62,7 +62,10 @@ class CustomFieldFilterSet(BaseFilterSet):
 
 
     class Meta:
     class Meta:
         model = CustomField
         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):
     def search(self, queryset, name, value):
         if not value.strip():
         if not value.strip():

+ 7 - 0
netbox/extras/forms/bulk_edit.py

@@ -37,6 +37,13 @@ class CustomFieldBulkEditForm(BulkEditForm):
     weight = forms.IntegerField(
     weight = forms.IntegerField(
         required=False
         required=False
     )
     )
+    ui_visibility = forms.ChoiceField(
+        label="UI visibility",
+        choices=add_blank_choice(CustomFieldVisibilityChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
 
 
     nullable_fields = ('group_name', 'description',)
     nullable_fields = ('group_name', 'description',)
 
 

+ 1 - 0
netbox/extras/forms/bulk_import.py

@@ -38,6 +38,7 @@ class CustomFieldCSVForm(CSVModelForm):
         fields = (
         fields = (
             'name', 'label', 'group_name', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic',
             'name', 'label', 'group_name', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic',
             'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
             '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 django.contrib.contenttypes.models import ContentType
 
 
 from extras.models import *
 from extras.models import *
+from extras.choices import CustomFieldVisibilityChoices
 
 
 __all__ = (
 __all__ = (
     'CustomFieldsMixin',
     'CustomFieldsMixin',
@@ -42,8 +43,18 @@ class CustomFieldsMixin:
         Append form fields for all CustomFields assigned to this object type.
         Append form fields for all CustomFields assigned to this object type.
         """
         """
         for customfield in self._get_custom_fields(self._get_content_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}'
             field_name = f'cf_{customfield.name}'
             self.fields[field_name] = self._get_form_field(customfield)
             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
             # Annotate the field in the list of CustomField form fields
             self.custom_fields[field_name] = customfield
             self.custom_fields[field_name] = customfield

+ 7 - 1
netbox/extras/forms/filtersets.py

@@ -32,7 +32,7 @@ __all__ = (
 class CustomFieldFilterForm(FilterForm):
 class CustomFieldFilterForm(FilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q',)),
         (None, ('q',)),
-        ('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required')),
+        ('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required', 'ui_visibility')),
     )
     )
     content_types = ContentTypeMultipleChoiceField(
     content_types = ContentTypeMultipleChoiceField(
         queryset=ContentType.objects.all(),
         queryset=ContentType.objects.all(),
@@ -56,6 +56,12 @@ class CustomFieldFilterForm(FilterForm):
             choices=BOOLEAN_WITH_BLANK_CHOICES
             choices=BOOLEAN_WITH_BLANK_CHOICES
         )
         )
     )
     )
+    ui_visibility = forms.ChoiceField(
+        choices=add_blank_choice(CustomFieldVisibilityChoices),
+        required=False,
+        label=_('UI visibility'),
+        widget=StaticSelect()
+    )
 
 
 
 
 class CustomLinkFilterForm(FilterForm):
 class CustomLinkFilterForm(FilterForm):

+ 2 - 1
netbox/extras/forms/models.py

@@ -43,7 +43,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
         ('Custom Field', (
         ('Custom Field', (
             'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description',
             'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description',
         )),
         )),
-        ('Behavior', ('filter_logic',)),
+        ('Behavior', ('filter_logic', 'ui_visibility')),
         ('Values', ('default', 'choices')),
         ('Values', ('default', 'choices')),
         ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
         ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
     )
     )
@@ -58,6 +58,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
         widgets = {
         widgets = {
             'type': StaticSelect(),
             'type': StaticSelect(),
             'filter_logic': 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,
         null=True,
         help_text='Comma-separated list of available choices (for selection fields)'
         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()
     objects = CustomFieldManager()
 
 
     class Meta:
     class Meta:

+ 2 - 1
netbox/extras/tables/tables.py

@@ -28,12 +28,13 @@ class CustomFieldTable(NetBoxTable):
     )
     )
     content_types = columns.ContentTypesColumn()
     content_types = columns.ContentTypesColumn()
     required = columns.BooleanColumn()
     required = columns.BooleanColumn()
+    ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = CustomField
         model = CustomField
         fields = (
         fields = (
             'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default',
             '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')
         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,
             'default': None,
             'weight': 200,
             'weight': 200,
             'required': True,
             'required': True,
+            'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE,
         }
         }
 
 
         cls.csv_data = (
         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 = {
         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 django.db import models
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
-from extras.choices import ObjectChangeActionChoices
+from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
 from extras.utils import register_features
 from extras.utils import register_features
 from netbox.signals import post_clean
 from netbox.signals import post_clean
 from utilities.utils import serialize_object
 from utilities.utils import serialize_object
@@ -100,7 +100,7 @@ class CustomFieldsMixin(models.Model):
         """
         """
         return self.custom_field_data
         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}`.
         Return a dictionary of custom fields for a single object in the form `{field: value}`.
 
 
@@ -114,6 +114,10 @@ class CustomFieldsMixin(models.Model):
 
 
         data = {}
         data = {}
         for field in CustomField.objects.get_for_model(self):
         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)
             value = self.custom_field_data.get(field.name)
             data[field] = field.deserialize(value)
             data[field] = field.deserialize(value)
 
 
@@ -121,10 +125,10 @@ class CustomFieldsMixin(models.Model):
 
 
     def get_custom_fields_by_group(self):
     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)
         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
             grouped_custom_fields[cf.group_name][cf] = value
 
 
         return dict(grouped_custom_fields)
         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 django_tables2.data import TableQuerysetData
 
 
 from extras.models import CustomField, CustomLink
 from extras.models import CustomField, CustomLink
+from extras.choices import CustomFieldVisibilityChoices
 from netbox.tables import columns
 from netbox.tables import columns
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 
 
@@ -178,7 +179,10 @@ class NetBoxTable(BaseTable):
 
 
         # Add custom field & custom link columns
         # Add custom field & custom link columns
         content_type = ContentType.objects.get_for_model(self._meta.model)
         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([
         extra_columns.extend([
             (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields
             (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>
             <th scope="row">Weight</th>
             <td>{{ object.weight }}</td>
             <td>{{ object.weight }}</td>
           </tr>
           </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>
         </table>
       </div>
       </div>
     </div>
     </div>
@@ -65,10 +73,6 @@
               {% endif %}
               {% endif %}
             </td>
             </td>
           </tr>
           </tr>
-          <tr>
-            <th scope="row">Filter Logic</th>
-            <td>{{ object.get_filter_logic_display }}</td>
-          </tr>
         </table>
         </table>
       </div>
       </div>
     </div>
     </div>

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

@@ -78,31 +78,39 @@
     </div>
     </div>
 	<div class="col col-md-6">
 	<div class="col col-md-6">
         <div class="card">
         <div class="card">
-            <h5 class="card-header">
-                Cluster
-            </h5>
+            <h5 class="card-header">Cluster</h5>
             <div class="card-body">
             <div class="card-body">
                 <table class="table table-hover attr-table">
                 <table class="table table-hover attr-table">
+                    <tr>
+                        <th scope="row">Site</th>
+                        <td>
+                            {{ object.site|linkify|placeholder }}
+                        </td>
+                    </tr>
                     <tr>
                     <tr>
                         <th scope="row">Cluster</th>
                         <th scope="row">Cluster</th>
                         <td>
                         <td>
                             {% if object.cluster.group %}
                             {% if object.cluster.group %}
                                 {{ object.cluster.group|linkify }} /
                                 {{ object.cluster.group|linkify }} /
                             {% endif %}
                             {% endif %}
-                            {{ object.cluster|linkify }}
+                            {{ object.cluster|linkify|placeholder }}
                         </td>
                         </td>
                     </tr>
                     </tr>
                     <tr>
                     <tr>
                         <th scope="row">Cluster Type</th>
                         <th scope="row">Cluster Type</th>
                         <td>{{ object.cluster.type }}</td>
                         <td>{{ object.cluster.type }}</td>
                     </tr>
                     </tr>
+                    <tr>
+                        <th scope="row">Device</th>
+                        <td>
+                            {{ object.device|linkify|placeholder }}
+                        </td>
+                    </tr>
                 </table>
                 </table>
             </div>
             </div>
         </div>
         </div>
         <div class="card">
         <div class="card">
-            <h5 class="card-header">
-                Resources
-            </h5>
+            <h5 class="card-header">Resources</h5>
             <div class="card-body">
             <div class="card-body">
                 <table class="table table-hover attr-table">
                 <table class="table table-hover attr-table">
                     <tr>
                     <tr>

+ 4 - 3
netbox/utilities/testing/utils.py

@@ -34,15 +34,16 @@ def post_data(data):
     return ret
     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).
     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')
     manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')
     devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer)
     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')
     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
     return device
 
 

+ 15 - 11
netbox/virtualization/api/serializers.py

@@ -1,7 +1,9 @@
 from drf_yasg.utils import swagger_serializer_method
 from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 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 dcim.choices import InterfaceModeChoices
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer
 from ipam.models import VLAN
 from ipam.models import VLAN
@@ -45,6 +47,7 @@ class ClusterSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
     type = NestedClusterTypeSerializer()
     type = NestedClusterTypeSerializer()
     group = NestedClusterGroupSerializer(required=False, allow_null=True, default=None)
     group = NestedClusterGroupSerializer(required=False, allow_null=True, default=None)
+    status = ChoiceField(choices=ClusterStatusChoices, required=False)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer(required=False, allow_null=True, default=None)
     site = NestedSiteSerializer(required=False, allow_null=True, default=None)
     device_count = serializers.IntegerField(read_only=True)
     device_count = serializers.IntegerField(read_only=True)
@@ -53,8 +56,8 @@ class ClusterSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
         fields = [
         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):
 class VirtualMachineSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
     url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
     status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
     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)
     role = NestedDeviceRoleSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     platform = NestedPlatformSerializer(required=False, allow_null=True)
     platform = NestedPlatformSerializer(required=False, allow_null=True)
@@ -77,9 +81,9 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
         fields = [
         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 = []
         validators = []
 
 
@@ -89,9 +93,9 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
 
 
     class Meta(VirtualMachineSerializer.Meta):
     class Meta(VirtualMachineSerializer.Meta):
         fields = [
         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)
     @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):
 class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
     queryset = VirtualMachine.objects.prefetch_related(
     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
     filterset_class = filtersets.VirtualMachineFilterSet
 
 

+ 22 - 0
netbox/virtualization/choices.py

@@ -1,6 +1,28 @@
 from utilities.choices import ChoiceSet
 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
 # VirtualMachines
 #
 #

+ 20 - 7
netbox/virtualization/filtersets.py

@@ -1,7 +1,7 @@
 import django_filters
 import django_filters
 from django.db.models import Q
 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 extras.filtersets import LocalConfigContextFilterSet
 from ipam.models import VRF
 from ipam.models import VRF
 from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
 from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
@@ -90,6 +90,10 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
         to_field_name='slug',
         to_field_name='slug',
         label='Cluster type (slug)',
         label='Cluster type (slug)',
     )
     )
+    status = django_filters.MultipleChoiceFilter(
+        choices=ClusterStatusChoices,
+        null_value=None
+    )
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
@@ -146,39 +150,48 @@ class VirtualMachineFilterSet(
         to_field_name='name',
         to_field_name='name',
         label='Cluster',
         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(
     region_id = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='cluster__site__region',
+        field_name='site__region',
         lookup_expr='in',
         lookup_expr='in',
         label='Region (ID)',
         label='Region (ID)',
     )
     )
     region = TreeNodeMultipleChoiceFilter(
     region = TreeNodeMultipleChoiceFilter(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
-        field_name='cluster__site__region',
+        field_name='site__region',
         lookup_expr='in',
         lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Region (slug)',
         label='Region (slug)',
     )
     )
     site_group_id = TreeNodeMultipleChoiceFilter(
     site_group_id = TreeNodeMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
-        field_name='cluster__site__group',
+        field_name='site__group',
         lookup_expr='in',
         lookup_expr='in',
         label='Site group (ID)',
         label='Site group (ID)',
     )
     )
     site_group = TreeNodeMultipleChoiceFilter(
     site_group = TreeNodeMultipleChoiceFilter(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
-        field_name='cluster__site__group',
+        field_name='site__group',
         lookup_expr='in',
         lookup_expr='in',
         to_field_name='slug',
         to_field_name='slug',
         label='Site group (slug)',
         label='Site group (slug)',
     )
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='cluster__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
-        field_name='cluster__site__slug',
+        field_name='site__slug',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Site (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.choices import InterfaceModeChoices
 from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
 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 ipam.models import VLAN, VRF
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -58,6 +58,12 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         required=False
         required=False
     )
     )
+    status = forms.ChoiceField(
+        choices=add_blank_choice(ClusterStatusChoices),
+        required=False,
+        initial='',
+        widget=StaticSelect()
+    )
     tenant = DynamicModelChoiceField(
     tenant = DynamicModelChoiceField(
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False
         required=False
@@ -85,7 +91,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = Cluster
     model = Cluster
     fieldsets = (
     fieldsets = (
-        (None, ('type', 'group', 'tenant',)),
+        (None, ('type', 'group', 'status', 'tenant',)),
         ('Site', ('region', 'site_group', 'site',)),
         ('Site', ('region', 'site_group', 'site',)),
     )
     )
     nullable_fields = (
     nullable_fields = (
@@ -100,9 +106,23 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
         initial='',
         initial='',
         widget=StaticSelect(),
         widget=StaticSelect(),
     )
     )
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all(),
+        required=False
+    )
     cluster = DynamicModelChoiceField(
     cluster = DynamicModelChoiceField(
         queryset=Cluster.objects.all(),
         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(
     role = DynamicModelChoiceField(
         queryset=DeviceRole.objects.filter(
         queryset=DeviceRole.objects.filter(
@@ -140,11 +160,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
 
 
     model = VirtualMachine
     model = VirtualMachine
     fieldsets = (
     fieldsets = (
-        (None, ('cluster', 'status', 'role', 'tenant', 'platform')),
+        (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform')),
         ('Resources', ('vcpus', 'memory', 'disk'))
         ('Resources', ('vcpus', 'memory', 'disk'))
     )
     )
     nullable_fields = (
     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
             # See 5643
             if 'pk' in self.initial:
             if 'pk' in self.initial:
                 site = None
                 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
                 # 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.choices import InterfaceModeChoices
-from dcim.models import DeviceRole, Platform, Site
+from dcim.models import Device, DeviceRole, Platform, Site
 from ipam.models import VRF
 from ipam.models import VRF
 from netbox.forms import NetBoxModelCSVForm
 from netbox.forms import NetBoxModelCSVForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -44,6 +44,10 @@ class ClusterCSVForm(NetBoxModelCSVForm):
         required=False,
         required=False,
         help_text='Assigned cluster group'
         help_text='Assigned cluster group'
     )
     )
+    status = CSVChoiceField(
+        choices=ClusterStatusChoices,
+        help_text='Operational status'
+    )
     site = CSVModelChoiceField(
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -59,7 +63,7 @@ class ClusterCSVForm(NetBoxModelCSVForm):
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
-        fields = ('name', 'type', 'group', 'site', 'comments')
+        fields = ('name', 'type', 'group', 'status', 'site', 'comments')
 
 
 
 
 class VirtualMachineCSVForm(NetBoxModelCSVForm):
 class VirtualMachineCSVForm(NetBoxModelCSVForm):
@@ -67,11 +71,24 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
         choices=VirtualMachineStatusChoices,
         choices=VirtualMachineStatusChoices,
         help_text='Operational status'
         help_text='Operational status'
     )
     )
+    site = CSVModelChoiceField(
+        queryset=Site.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Assigned site'
+    )
     cluster = CSVModelChoiceField(
     cluster = CSVModelChoiceField(
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
         to_field_name='name',
         to_field_name='name',
+        required=False,
         help_text='Assigned cluster'
         help_text='Assigned cluster'
     )
     )
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Assigned device within cluster'
+    )
     role = CSVModelChoiceField(
     role = CSVModelChoiceField(
         queryset=DeviceRole.objects.filter(
         queryset=DeviceRole.objects.filter(
             vm_role=True
             vm_role=True
@@ -96,7 +113,8 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
         fields = (
         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 import forms
 from django.utils.translation import gettext as _
 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 extras.forms import LocalConfigContextFilterForm
 from ipam.models import VRF
 from ipam.models import VRF
 from netbox.forms import NetBoxModelFilterSetForm
 from netbox.forms import NetBoxModelFilterSetForm
@@ -35,7 +35,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
     model = Cluster
     model = Cluster
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (None, ('q', 'tag')),
-        ('Attributes', ('group_id', 'type_id')),
+        ('Attributes', ('group_id', 'type_id', 'status')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Contacts', ('contact', 'contact_role')),
         ('Contacts', ('contact', 'contact_role')),
@@ -50,6 +50,10 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
         required=False,
         required=False,
         label=_('Region')
         label=_('Region')
     )
     )
+    status = MultipleChoiceField(
+        choices=ClusterStatusChoices,
+        required=False
+    )
     site_group_id = DynamicModelMultipleChoiceField(
     site_group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
         required=False,
         required=False,
@@ -83,7 +87,7 @@ class VirtualMachineFilterForm(
     model = VirtualMachine
     model = VirtualMachine
     fieldsets = (
     fieldsets = (
         (None, ('q', 'tag')),
         (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')),
         ('Location', ('region_id', 'site_group_id', 'site_id')),
         ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
         ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
         ('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -106,6 +110,11 @@ class VirtualMachineFilterForm(
         required=False,
         required=False,
         label=_('Cluster')
         label=_('Cluster')
     )
     )
+    device_id = DynamicModelMultipleChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        label=_('Device')
+    )
     region_id = DynamicModelMultipleChoiceField(
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         queryset=Region.objects.all(),
         required=False,
         required=False,

+ 22 - 6
netbox/virtualization/forms/models.py

@@ -79,15 +79,19 @@ class ClusterForm(TenancyForm, NetBoxModelForm):
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     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')),
         ('Tenancy', ('tenant_group', 'tenant')),
     )
     )
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
         fields = (
         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):
 class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
@@ -161,6 +165,9 @@ class ClusterRemoveDevicesForm(ConfirmationForm):
 
 
 
 
 class VirtualMachineForm(TenancyForm, NetBoxModelForm):
 class VirtualMachineForm(TenancyForm, NetBoxModelForm):
+    site = DynamicModelChoiceField(
+        queryset=Site.objects.all()
+    )
     cluster_group = DynamicModelChoiceField(
     cluster_group = DynamicModelChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         required=False,
         required=False,
@@ -172,7 +179,15 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
     cluster = DynamicModelChoiceField(
     cluster = DynamicModelChoiceField(
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
         query_params={
         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(
     role = DynamicModelChoiceField(
@@ -193,7 +208,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
 
 
     fieldsets = (
     fieldsets = (
         ('Virtual Machine', ('name', 'role', 'status', 'tags')),
         ('Virtual Machine', ('name', 'role', 'status', 'tags')),
-        ('Cluster', ('cluster_group', 'cluster')),
+        ('Cluster', ('site', 'cluster_group', 'cluster', 'device')),
         ('Tenancy', ('tenant_group', 'tenant')),
         ('Tenancy', ('tenant_group', 'tenant')),
         ('Management', ('platform', 'primary_ip4', 'primary_ip6')),
         ('Management', ('platform', 'primary_ip4', 'primary_ip6')),
         ('Resources', ('vcpus', 'memory', 'disk')),
         ('Resources', ('vcpus', 'memory', 'disk')),
@@ -203,8 +218,9 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
     class Meta:
     class Meta:
         model = VirtualMachine
         model = VirtualMachine
         fields = [
         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 = {
         help_texts = {
             'local_context_data': "Local config context data overwrites all sources contexts in the final rendered "
             '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,
         blank=True,
         null=True
         null=True
     )
     )
+    status = models.CharField(
+        max_length=50,
+        choices=ClusterStatusChoices,
+        default=ClusterStatusChoices.STATUS_ACTIVE
+    )
     tenant = models.ForeignKey(
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         to='tenancy.Tenant',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
@@ -165,6 +170,9 @@ class Cluster(NetBoxModel):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('virtualization:cluster', args=[self.pk])
         return reverse('virtualization:cluster', args=[self.pk])
 
 
+    def get_status_color(self):
+        return ClusterStatusChoices.colors.get(self.status)
+
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
@@ -187,10 +195,26 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
     """
     """
     A virtual machine which runs inside a Cluster.
     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(
     cluster = models.ForeignKey(
         to='virtualization.Cluster',
         to='virtualization.Cluster',
         on_delete=models.PROTECT,
         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(
     tenant = models.ForeignKey(
         to='tenancy.Tenant',
         to='tenancy.Tenant',
@@ -276,7 +300,7 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
     objects = ConfigContextModelQuerySet.as_manager()
     objects = ConfigContextModelQuerySet.as_manager()
 
 
     clone_fields = [
     clone_fields = [
-        'cluster', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
+        'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
     ]
     ]
 
 
     class Meta:
     class Meta:
@@ -308,6 +332,28 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
     def clean(self):
     def clean(self):
         super().clean()
         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
         # Validate primary IP addresses
         interfaces = self.interfaces.all()
         interfaces = self.interfaces.all()
         for field in ['primary_ip4', 'primary_ip6']:
         for field in ['primary_ip4', 'primary_ip6']:
@@ -336,10 +382,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
         else:
         else:
             return None
             return None
 
 
-    @property
-    def site(self):
-        return self.cluster.site
-
 
 
 #
 #
 # Interfaces
 # Interfaces

+ 4 - 3
netbox/virtualization/tables/clusters.py

@@ -66,6 +66,7 @@ class ClusterTable(NetBoxTable):
     group = tables.Column(
     group = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    status = columns.ChoiceFieldColumn()
     tenant = tables.Column(
     tenant = tables.Column(
         linkify=True
         linkify=True
     )
     )
@@ -93,7 +94,7 @@ class ClusterTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Cluster
         model = Cluster
         fields = (
         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
         linkify=True
     )
     )
     status = columns.ChoiceFieldColumn()
     status = columns.ChoiceFieldColumn()
+    site = tables.Column(
+        linkify=True
+    )
     cluster = tables.Column(
     cluster = tables.Column(
         linkify=True
         linkify=True
     )
     )
+    device = tables.Column(
+        linkify=True
+    )
     role = columns.ColoredLabelColumn()
     role = columns.ColoredLabelColumn()
     tenant = TenantColumn()
     tenant = TenantColumn()
     comments = columns.MarkdownColumn()
     comments = columns.MarkdownColumn()
@@ -56,11 +62,11 @@ class VirtualMachineTable(NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = VirtualMachine
         model = VirtualMachine
         fields = (
         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 = (
         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 rest_framework import status
 
 
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
+from dcim.models import Site
 from ipam.models import VLAN, VRF
 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
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 
 
 
@@ -85,6 +87,7 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
     model = Cluster
     model = Cluster
     brief_fields = ['display', 'id', 'name', 'url', 'virtualmachine_count']
     brief_fields = ['display', 'id', 'name', 'url', 'virtualmachine_count']
     bulk_update_data = {
     bulk_update_data = {
+        'status': 'offline',
         'comments': 'New comment',
         'comments': 'New comment',
     }
     }
 
 
@@ -104,9 +107,9 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
         ClusterGroup.objects.bulk_create(cluster_groups)
         ClusterGroup.objects.bulk_create(cluster_groups)
 
 
         clusters = (
         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)
         Cluster.objects.bulk_create(clusters)
 
 
@@ -115,16 +118,19 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
                 'name': 'Cluster 4',
                 'name': 'Cluster 4',
                 'type': cluster_types[1].pk,
                 'type': cluster_types[1].pk,
                 'group': cluster_groups[1].pk,
                 'group': cluster_groups[1].pk,
+                'status': ClusterStatusChoices.STATUS_STAGING,
             },
             },
             {
             {
                 'name': 'Cluster 5',
                 'name': 'Cluster 5',
                 'type': cluster_types[1].pk,
                 'type': cluster_types[1].pk,
                 'group': cluster_groups[1].pk,
                 'group': cluster_groups[1].pk,
+                'status': ClusterStatusChoices.STATUS_STAGING,
             },
             },
             {
             {
                 'name': 'Cluster 6',
                 'name': 'Cluster 6',
                 'type': cluster_types[1].pk,
                 'type': cluster_types[1].pk,
                 'group': cluster_groups[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')
         clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
         clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-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 = (
         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)
         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 = (
         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)
         VirtualMachine.objects.bulk_create(virtual_machines)
 
 
         cls.create_data = [
         cls.create_data = [
             {
             {
                 'name': 'Virtual Machine 4',
                 'name': 'Virtual Machine 4',
+                'site': sites[1].pk,
                 'cluster': clusters[1].pk,
                 'cluster': clusters[1].pk,
+                'device': device2.pk,
             },
             },
             {
             {
                 'name': 'Virtual Machine 5',
                 'name': 'Virtual Machine 5',
+                'site': sites[1].pk,
                 'cluster': clusters[1].pk,
                 'cluster': clusters[1].pk,
             },
             },
             {
             {
                 'name': 'Virtual Machine 6',
                 '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 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 ipam.models import IPAddress, VRF
 from tenancy.models import Tenant, TenantGroup
 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.choices import *
 from virtualization.filtersets import *
 from virtualization.filtersets import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -123,9 +123,9 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
         Tenant.objects.bulk_create(tenants)
         Tenant.objects.bulk_create(tenants)
 
 
         clusters = (
         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)
         Cluster.objects.bulk_create(clusters)
 
 
@@ -161,6 +161,10 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'group': [groups[0].slug, groups[1].slug]}
         params = {'group': [groups[0].slug, groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_type(self):
         types = ClusterType.objects.all()[:2]
         types = ClusterType.objects.all()[:2]
         params = {'type_id': [types[0].pk, types[1].pk]}
         params = {'type_id': [types[0].pk, types[1].pk]}
@@ -221,9 +225,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
             site_group.save()
             site_group.save()
 
 
         sites = (
         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)
         Site.objects.bulk_create(sites)
 
 
@@ -248,6 +252,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         DeviceRole.objects.bulk_create(roles)
         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 = (
         tenant_groups = (
             TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
             TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
@@ -264,9 +274,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
         Tenant.objects.bulk_create(tenants)
         Tenant.objects.bulk_create(tenants)
 
 
         vms = (
         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)
         VirtualMachine.objects.bulk_create(vms)
 
 
@@ -327,6 +337,13 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'cluster': [clusters[0].name, clusters[1].name]}
         params = {'cluster': [clusters[0].name, clusters[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_region(self):
         regions = Region.objects.all()[:2]
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
         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.core.exceptions import ValidationError
 from django.test import TestCase
 from django.test import TestCase
 
 
+from dcim.models import Site
 from virtualization.models import *
 from virtualization.models import *
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 
 
 
 
 class VirtualMachineTestCase(TestCase):
 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):
     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(
         vm1 = VirtualMachine(
-            cluster=self.cluster,
+            cluster=cluster,
             name='Test VM 1'
             name='Test VM 1'
         )
         )
         vm1.save()
         vm1.save()
@@ -43,3 +41,33 @@ class VirtualMachineTestCase(TestCase):
         # Two VMs assigned to the same Cluster and different Tenants should pass validation
         # Two VMs assigned to the same Cluster and different Tenants should pass validation
         vm2.full_clean()
         vm2.full_clean()
         vm2.save()
         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.choices import InterfaceModeChoices
 from dcim.models import DeviceRole, Platform, Site
 from dcim.models import DeviceRole, Platform, Site
 from ipam.models import VLAN, VRF
 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.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 
@@ -101,9 +101,9 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         ClusterType.objects.bulk_create(clustertypes)
         ClusterType.objects.bulk_create(clustertypes)
 
 
         Cluster.objects.bulk_create([
         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')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -112,6 +112,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'name': 'Cluster X',
             'name': 'Cluster X',
             'group': clustergroups[1].pk,
             'group': clustergroups[1].pk,
             'type': clustertypes[1].pk,
             'type': clustertypes[1].pk,
+            'status': ClusterStatusChoices.STATUS_OFFLINE,
             'tenant': None,
             'tenant': None,
             'site': sites[1].pk,
             'site': sites[1].pk,
             'comments': 'Some comments',
             'comments': 'Some comments',
@@ -119,15 +120,16 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         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 = {
         cls.bulk_edit_data = {
             'group': clustergroups[1].pk,
             'group': clustergroups[1].pk,
             'type': clustertypes[1].pk,
             'type': clustertypes[1].pk,
+            'status': ClusterStatusChoices.STATUS_OFFLINE,
             'tenant': None,
             'tenant': None,
             'site': sites[1].pk,
             'site': sites[1].pk,
             'comments': 'New comments',
             'comments': 'New comments',
@@ -166,24 +168,37 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         Platform.objects.bulk_create(platforms)
         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')
         clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
 
 
         clusters = (
         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)
         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.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')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'cluster': clusters[1].pk,
             'cluster': clusters[1].pk,
+            'device': devices[1].pk,
+            'site': sites[1].pk,
             'tenant': None,
             'tenant': None,
             'platform': platforms[1].pk,
             'platform': platforms[1].pk,
             'name': 'Virtual Machine X',
             'name': 'Virtual Machine X',
@@ -200,14 +215,16 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         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 = {
         cls.bulk_edit_data = {
+            'site': sites[1].pk,
             'cluster': clusters[1].pk,
             'cluster': clusters[1].pk,
+            'device': devices[1].pk,
             'tenant': None,
             'tenant': None,
             'platform': platforms[1].pk,
             'platform': platforms[1].pk,
             'status': VirtualMachineStatusChoices.STATUS_STAGED,
             'status': VirtualMachineStatusChoices.STATUS_STAGED,
@@ -243,8 +260,8 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
         clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
         cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site)
         cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site)
         virtualmachines = (
         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)
         VirtualMachine.objects.bulk_create(virtualmachines)