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

7699 Add Scope to Cluster (#17848)

* 7699 Add Scope to Cluster

* 7699 Serializer

* 7699 filterset

* 7699 bulk_edit

* 7699 bulk_import

* 7699 model_form

* 7699 graphql, tables

* 7699 fixes

* 7699 fixes

* 7699 fixes

* 7699 fixes

* 7699 fix tests

* 7699 fix graphql tests for clusters reference

* 7699 fix dcim tests

* 7699 fix ipam tests

* 7699 fix tests

* 7699 use mixin for model

* 7699 change mixin name

* 7699 scope form

* 7699 scope form

* 7699 scoped form, fitlerset

* 7699 review changes

* 7699 move ScopedFilterset

* 7699 move CachedScopeMixin

* 7699 review changes

* 7699 review changes

* 7699 refactor mixins

* 7699 _sitegroup -> _site_group

* 7699 update docstring

* Misc cleanup

* Update migrations

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Arthur Hanson 1 год назад
Родитель
Сommit
6dc75d8db1
29 измененных файлов с 588 добавлено и 156 удалено
  1. 2 2
      docs/models/virtualization/cluster.md
  2. 5 0
      netbox/dcim/constants.py
  3. 58 0
      netbox/dcim/filtersets.py
  4. 105 0
      netbox/dcim/forms/mixins.py
  5. 17 0
      netbox/dcim/graphql/types.py
  6. 9 2
      netbox/dcim/models/devices.py
  7. 85 0
      netbox/dcim/models/mixins.py
  8. 5 4
      netbox/dcim/tests/test_models.py
  9. 2 2
      netbox/extras/tests/test_models.py
  10. 1 1
      netbox/ipam/querysets.py
  11. 5 4
      netbox/ipam/tests/test_filtersets.py
  12. 6 2
      netbox/templates/virtualization/cluster.html
  13. 27 4
      netbox/virtualization/api/serializers_/clusters.py
  14. 1 1
      netbox/virtualization/apps.py
  15. 3 39
      netbox/virtualization/filtersets.py
  16. 5 23
      netbox/virtualization/forms/bulk_edit.py
  17. 6 2
      netbox/virtualization/forms/bulk_import.py
  18. 12 7
      netbox/virtualization/forms/filtersets.py
  19. 5 9
      netbox/virtualization/forms/model_forms.py
  20. 11 4
      netbox/virtualization/graphql/types.py
  21. 51 0
      netbox/virtualization/migrations/0044_cluster_scope.py
  22. 94 0
      netbox/virtualization/migrations/0045_clusters_cached_relations.py
  23. 30 18
      netbox/virtualization/models/clusters.py
  24. 2 2
      netbox/virtualization/models/virtualmachines.py
  25. 7 4
      netbox/virtualization/tables/clusters.py
  26. 6 4
      netbox/virtualization/tests/test_api.py
  27. 10 8
      netbox/virtualization/tests/test_filtersets.py
  28. 5 4
      netbox/virtualization/tests/test_models.py
  29. 13 10
      netbox/virtualization/tests/test_views.py

+ 2 - 2
docs/models/virtualization/cluster.md

@@ -23,6 +23,6 @@ The cluster's operational status.
 !!! tip
     Additional statuses may be defined by setting `Cluster.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
 
-### Site
+### Scope
 
-The [site](../dcim/site.md) with which the cluster is associated.
+The [region](../dcim/region.md), [site](../dcim/site.md), [site group](../dcim/sitegroup.md) or [location](../dcim/location.md) with which this cluster is associated.

+ 5 - 0
netbox/dcim/constants.py

@@ -123,3 +123,8 @@ COMPATIBLE_TERMINATION_TYPES = {
     'powerport': ['poweroutlet', 'powerfeed'],
     'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
 }
+
+# Models which can serve to scope an object by location
+LOCATION_SCOPE_TYPES = (
+    'region', 'sitegroup', 'site', 'location',
+)

+ 58 - 0
netbox/dcim/filtersets.py

@@ -73,6 +73,7 @@ __all__ = (
     'RearPortFilterSet',
     'RearPortTemplateFilterSet',
     'RegionFilterSet',
+    'ScopedFilterSet',
     'SiteFilterSet',
     'SiteGroupFilterSet',
     'VirtualChassisFilterSet',
@@ -2344,3 +2345,60 @@ class InterfaceConnectionFilterSet(ConnectionFilterSet):
     class Meta:
         model = Interface
         fields = tuple()
+
+
+class ScopedFilterSet(BaseFilterSet):
+    """
+    Provides additional filtering functionality for location, site, etc.. for Scoped models.
+    """
+    scope_type = ContentTypeFilter()
+    region_id = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='_region',
+        lookup_expr='in',
+        label=_('Region (ID)'),
+    )
+    region = TreeNodeMultipleChoiceFilter(
+        queryset=Region.objects.all(),
+        field_name='_region',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Region (slug)'),
+    )
+    site_group_id = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='_site_group',
+        lookup_expr='in',
+        label=_('Site group (ID)'),
+    )
+    site_group = TreeNodeMultipleChoiceFilter(
+        queryset=SiteGroup.objects.all(),
+        field_name='_site_group',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Site group (slug)'),
+    )
+    site_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Site.objects.all(),
+        field_name='_site',
+        label=_('Site (ID)'),
+    )
+    site = django_filters.ModelMultipleChoiceFilter(
+        field_name='_site__slug',
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        label=_('Site (slug)'),
+    )
+    location_id = TreeNodeMultipleChoiceFilter(
+       queryset=Location.objects.all(),
+       field_name='_location',
+       lookup_expr='in',
+       label=_('Location (ID)'),
+    )
+    location = TreeNodeMultipleChoiceFilter(
+        queryset=Location.objects.all(),
+        field_name='_location',
+        lookup_expr='in',
+        to_field_name='slug',
+        label=_('Location (slug)'),
+    )

+ 105 - 0
netbox/dcim/forms/mixins.py

@@ -0,0 +1,105 @@
+from django import forms
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ObjectDoesNotExist
+from django.utils.translation import gettext_lazy as _
+
+from dcim.constants import LOCATION_SCOPE_TYPES
+from dcim.models import Site
+from utilities.forms import get_field_value
+from utilities.forms.fields import (
+    ContentTypeChoiceField, CSVContentTypeField, DynamicModelChoiceField,
+)
+from utilities.templatetags.builtins.filters import bettertitle
+from utilities.forms.widgets import HTMXSelect
+
+__all__ = (
+    'ScopedBulkEditForm',
+    'ScopedForm',
+    'ScopedImportForm',
+)
+
+
+class ScopedForm(forms.Form):
+    scope_type = ContentTypeChoiceField(
+        queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES),
+        widget=HTMXSelect(),
+        required=False,
+        label=_('Scope type')
+    )
+    scope = DynamicModelChoiceField(
+        label=_('Scope'),
+        queryset=Site.objects.none(),  # Initial queryset
+        required=False,
+        disabled=True,
+        selector=True
+    )
+
+    def __init__(self, *args, **kwargs):
+        instance = kwargs.get('instance')
+        initial = kwargs.get('initial', {})
+
+        if instance is not None and instance.scope:
+            initial['scope'] = instance.scope
+            kwargs['initial'] = initial
+
+        super().__init__(*args, **kwargs)
+        self._set_scoped_values()
+
+    def clean(self):
+        super().clean()
+
+        # Assign the selected scope (if any)
+        self.instance.scope = self.cleaned_data.get('scope')
+
+    def _set_scoped_values(self):
+        if scope_type_id := get_field_value(self, 'scope_type'):
+            try:
+                scope_type = ContentType.objects.get(pk=scope_type_id)
+                model = scope_type.model_class()
+                self.fields['scope'].queryset = model.objects.all()
+                self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
+                self.fields['scope'].disabled = False
+                self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
+            except ObjectDoesNotExist:
+                pass
+
+            if self.instance and scope_type_id != self.instance.scope_type_id:
+                self.initial['scope'] = None
+
+
+class ScopedBulkEditForm(forms.Form):
+    scope_type = ContentTypeChoiceField(
+        queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES),
+        widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
+        required=False,
+        label=_('Scope type')
+    )
+    scope = DynamicModelChoiceField(
+        label=_('Scope'),
+        queryset=Site.objects.none(),  # Initial queryset
+        required=False,
+        disabled=True,
+        selector=True
+    )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        if scope_type_id := get_field_value(self, 'scope_type'):
+            try:
+                scope_type = ContentType.objects.get(pk=scope_type_id)
+                model = scope_type.model_class()
+                self.fields['scope'].queryset = model.objects.all()
+                self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
+                self.fields['scope'].disabled = False
+                self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
+            except ObjectDoesNotExist:
+                pass
+
+
+class ScopedImportForm(forms.Form):
+    scope_type = CSVContentTypeField(
+        queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES),
+        required=False,
+        label=_('Scope type (app & model)')
+    )

+ 17 - 0
netbox/dcim/graphql/types.py

@@ -463,6 +463,10 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     children: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]]
 
+    @strawberry_django.field
+    def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
+        return self._clusters.all()
+
     @strawberry_django.field
     def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
         return self.circuit_terminations.all()
@@ -710,6 +714,10 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
     def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
 
+    @strawberry_django.field
+    def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
+        return self._clusters.all()
+
     @strawberry_django.field
     def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
         return self.circuit_terminations.all()
@@ -735,9 +743,14 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     locations: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]]
     asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]]
+    circuit_terminations: List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]
     clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]
     vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
 
+    @strawberry_django.field
+    def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
+        return self._clusters.all()
+
     @strawberry_django.field
     def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
         return self.circuit_terminations.all()
@@ -758,6 +771,10 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
     def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
 
+    @strawberry_django.field
+    def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
+        return self._clusters.all()
+
     @strawberry_django.field
     def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
         return self.circuit_terminations.all()

+ 9 - 2
netbox/dcim/models/devices.py

@@ -958,10 +958,17 @@ class Device(
                 })
 
         # A Device can only be assigned to a Cluster in the same Site (or no Site)
-        if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
+        if self.cluster and self.cluster._site is not None and self.cluster._site != self.site:
             raise ValidationError({
                 'cluster': _("The assigned cluster belongs to a different site ({site})").format(
-                    site=self.cluster.site
+                    site=self.cluster._site
+                )
+            })
+
+        if self.cluster and self.cluster._location is not None and self.cluster._location != self.location:
+            raise ValidationError({
+                'cluster': _("The assigned cluster belongs to a different location ({location})").format(
+                    site=self.cluster._location
                 )
             })
 

+ 85 - 0
netbox/dcim/models/mixins.py

@@ -1,6 +1,10 @@
+from django.apps import apps
+from django.contrib.contenttypes.fields import GenericForeignKey
 from django.db import models
+from dcim.constants import LOCATION_SCOPE_TYPES
 
 __all__ = (
+    'CachedScopeMixin',
     'RenderConfigMixin',
 )
 
@@ -27,3 +31,84 @@ class RenderConfigMixin(models.Model):
             return self.role.config_template
         if self.platform and self.platform.config_template:
             return self.platform.config_template
+
+
+class CachedScopeMixin(models.Model):
+    """
+    Mixin for adding a GenericForeignKey scope to a model that can point to a Region, SiteGroup, Site, or Location.
+    Includes cached fields for each to allow efficient filtering. Appropriate validation must be done in the clean()
+    method as this does not have any as validation is generally model-specific.
+    """
+    scope_type = models.ForeignKey(
+        to='contenttypes.ContentType',
+        on_delete=models.PROTECT,
+        limit_choices_to=models.Q(model__in=LOCATION_SCOPE_TYPES),
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    scope_id = models.PositiveBigIntegerField(
+        blank=True,
+        null=True
+    )
+    scope = GenericForeignKey(
+        ct_field='scope_type',
+        fk_field='scope_id'
+    )
+
+    _location = models.ForeignKey(
+        to='dcim.Location',
+        on_delete=models.CASCADE,
+        related_name='_%(class)ss',
+        blank=True,
+        null=True
+    )
+    _site = models.ForeignKey(
+        to='dcim.Site',
+        on_delete=models.CASCADE,
+        related_name='_%(class)ss',
+        blank=True,
+        null=True
+    )
+    _region = models.ForeignKey(
+        to='dcim.Region',
+        on_delete=models.CASCADE,
+        related_name='_%(class)ss',
+        blank=True,
+        null=True
+    )
+    _site_group = models.ForeignKey(
+        to='dcim.SiteGroup',
+        on_delete=models.CASCADE,
+        related_name='_%(class)ss',
+        blank=True,
+        null=True
+    )
+
+    class Meta:
+        abstract = True
+
+    def save(self, *args, **kwargs):
+        # Cache objects associated with the terminating object (for filtering)
+        self.cache_related_objects()
+
+        super().save(*args, **kwargs)
+
+    def cache_related_objects(self):
+        self._region = self._site_group = self._site = self._location = None
+        if self.scope_type:
+            scope_type = self.scope_type.model_class()
+            if scope_type == apps.get_model('dcim', 'region'):
+                self._region = self.scope
+            elif scope_type == apps.get_model('dcim', 'sitegroup'):
+                self._site_group = self.scope
+            elif scope_type == apps.get_model('dcim', 'site'):
+                self._region = self.scope.region
+                self._site_group = self.scope.group
+                self._site = self.scope
+            elif scope_type == apps.get_model('dcim', 'location'):
+                self._region = self.scope.site.region
+                self._site_group = self.scope.site.group
+                self._site = self.scope.site
+                self._location = self.scope
+    cache_related_objects.alters_data = True

+ 5 - 4
netbox/dcim/tests/test_models.py

@@ -601,11 +601,12 @@ class DeviceTestCase(TestCase):
         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(name='Cluster 1', type=cluster_type, scope=sites[0]),
+            Cluster(name='Cluster 2', type=cluster_type, scope=sites[1]),
+            Cluster(name='Cluster 3', type=cluster_type, scope=None),
         )
-        Cluster.objects.bulk_create(clusters)
+        for cluster in clusters:
+            cluster.save()
 
         device_type = DeviceType.objects.first()
         device_role = DeviceRole.objects.first()

+ 2 - 2
netbox/extras/tests/test_models.py

@@ -274,7 +274,7 @@ class ConfigContextTest(TestCase):
             name="Cluster",
             group=cluster_group,
             type=cluster_type,
-            site=site,
+            scope=site,
         )
 
         region_context = ConfigContext.objects.create(
@@ -366,7 +366,7 @@ class ConfigContextTest(TestCase):
         """
         site = Site.objects.first()
         cluster_type = ClusterType.objects.create(name="Cluster Type")
-        cluster = Cluster.objects.create(name="Cluster", type=cluster_type, site=site)
+        cluster = Cluster.objects.create(name="Cluster", type=cluster_type, scope=site)
         vm_role = DeviceRole.objects.first()
 
         # Create a ConfigContext associated with the site

+ 1 - 1
netbox/ipam/querysets.py

@@ -148,7 +148,7 @@ class VLANQuerySet(RestrictedQuerySet):
 
         # Find all relevant VLANGroups
         q = Q()
-        site = vm.site or vm.cluster.site
+        site = vm.site or vm.cluster._site
         if vm.cluster:
             # Add VLANGroups scoped to the assigned cluster (or its group)
             q |= Q(

+ 5 - 4
netbox/ipam/tests/test_filtersets.py

@@ -1675,11 +1675,12 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
 
         cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
         clusters = (
-            Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0], site=sites[0]),
-            Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1], site=sites[1]),
-            Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2], site=sites[2]),
+            Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0], scope=sites[0]),
+            Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1], scope=sites[1]),
+            Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2], scope=sites[2]),
         )
-        Cluster.objects.bulk_create(clusters)
+        for cluster in clusters:
+            cluster.save()
 
         virtual_machines = (
             VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]),

+ 6 - 2
netbox/templates/virtualization/cluster.html

@@ -39,8 +39,12 @@
             </td>
         </tr>
         <tr>
-          <th scope="row">{% trans "Site" %}</th>
-          <td>{{ object.site|linkify|placeholder }}</td>
+          <th scope="row">{% trans "Scope" %}</th>
+          {% if object.scope %}
+            <td>{{ object.scope|linkify }} ({% trans object.scope_type.name %})</td>
+          {% else %}
+            <td>{{ ''|placeholder }}</td>
+          {% endif %}
         </tr>
       </table>
     </div>

+ 27 - 4
netbox/virtualization/api/serializers_/clusters.py

@@ -1,9 +1,13 @@
-from dcim.api.serializers_.sites import SiteSerializer
-from netbox.api.fields import ChoiceField, RelatedObjectCountField
+from dcim.constants import LOCATION_SCOPE_TYPES
+from django.contrib.contenttypes.models import ContentType
+from drf_spectacular.utils import extend_schema_field
+from rest_framework import serializers
+from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
 from netbox.api.serializers import NetBoxModelSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer
 from virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType
+from utilities.api import get_serializer_for_model
 
 __all__ = (
     'ClusterGroupSerializer',
@@ -45,7 +49,16 @@ class ClusterSerializer(NetBoxModelSerializer):
     group = ClusterGroupSerializer(nested=True, required=False, allow_null=True, default=None)
     status = ChoiceField(choices=ClusterStatusChoices, required=False)
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
-    site = SiteSerializer(nested=True, required=False, allow_null=True, default=None)
+    scope_type = ContentTypeField(
+        queryset=ContentType.objects.filter(
+            model__in=LOCATION_SCOPE_TYPES
+        ),
+        allow_null=True,
+        required=False,
+        default=None
+    )
+    scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
+    scope = serializers.SerializerMethodField(read_only=True)
 
     # Related object counts
     device_count = RelatedObjectCountField('devices')
@@ -54,8 +67,18 @@ class ClusterSerializer(NetBoxModelSerializer):
     class Meta:
         model = Cluster
         fields = [
-            'id', 'url', 'display_url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site',
+            'id', 'url', 'display_url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'scope_type', 'scope_id', 'scope',
             'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
             'virtualmachine_count',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description', 'virtualmachine_count')
+
+    @extend_schema_field(serializers.JSONField(allow_null=True))
+    def get_scope(self, obj):
+        if obj.scope_id is None:
+            return None
+        serializer = get_serializer_for_model(obj.scope)
+        context = {'request': self.context['request']}
+        return serializer(obj.scope, nested=True, context=context).data
+
+

+ 1 - 1
netbox/virtualization/apps.py

@@ -17,7 +17,7 @@ class VirtualizationConfig(AppConfig):
 
         # Register denormalized fields
         denormalized.register(VirtualMachine, 'cluster', {
-            'site': 'site',
+            'site': '_site',
         })
 
         # Register counters

+ 3 - 39
netbox/virtualization/filtersets.py

@@ -2,7 +2,7 @@ import django_filters
 from django.db.models import Q
 from django.utils.translation import gettext as _
 
-from dcim.filtersets import CommonInterfaceFilterSet
+from dcim.filtersets import CommonInterfaceFilterSet, ScopedFilterSet
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.models import ConfigTemplate
@@ -37,43 +37,7 @@ class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet)
         fields = ('id', 'name', 'slug', 'description')
 
 
-class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
-    region_id = TreeNodeMultipleChoiceFilter(
-        queryset=Region.objects.all(),
-        field_name='site__region',
-        lookup_expr='in',
-        label=_('Region (ID)'),
-    )
-    region = TreeNodeMultipleChoiceFilter(
-        queryset=Region.objects.all(),
-        field_name='site__region',
-        lookup_expr='in',
-        to_field_name='slug',
-        label=_('Region (slug)'),
-    )
-    site_group_id = TreeNodeMultipleChoiceFilter(
-        queryset=SiteGroup.objects.all(),
-        field_name='site__group',
-        lookup_expr='in',
-        label=_('Site group (ID)'),
-    )
-    site_group = TreeNodeMultipleChoiceFilter(
-        queryset=SiteGroup.objects.all(),
-        field_name='site__group',
-        lookup_expr='in',
-        to_field_name='slug',
-        label=_('Site group (slug)'),
-    )
-    site_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Site.objects.all(),
-        label=_('Site (ID)'),
-    )
-    site = django_filters.ModelMultipleChoiceFilter(
-        field_name='site__slug',
-        queryset=Site.objects.all(),
-        to_field_name='slug',
-        label=_('Site (slug)'),
-    )
+class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ScopedFilterSet, ContactModelFilterSet):
     group_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ClusterGroup.objects.all(),
         label=_('Parent group (ID)'),
@@ -101,7 +65,7 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
 
     class Meta:
         model = Cluster
-        fields = ('id', 'name', 'description')
+        fields = ('id', 'name', 'description', 'scope_id')
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 5 - 23
netbox/virtualization/forms/bulk_edit.py

@@ -3,7 +3,8 @@ from django.utils.translation import gettext_lazy as _
 
 from dcim.choices import InterfaceModeChoices
 from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
-from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
+from dcim.forms.mixins import ScopedBulkEditForm
+from dcim.models import Device, DeviceRole, Platform, Site
 from extras.models import ConfigTemplate
 from ipam.models import VLAN, VLANGroup, VRF
 from netbox.forms import NetBoxModelBulkEditForm
@@ -55,7 +56,7 @@ class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm):
     nullable_fields = ('description',)
 
 
-class ClusterBulkEditForm(NetBoxModelBulkEditForm):
+class ClusterBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm):
     type = DynamicModelChoiceField(
         label=_('Type'),
         queryset=ClusterType.objects.all(),
@@ -77,25 +78,6 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
         queryset=Tenant.objects.all(),
         required=False
     )
-    region = DynamicModelChoiceField(
-        label=_('Region'),
-        queryset=Region.objects.all(),
-        required=False,
-    )
-    site_group = DynamicModelChoiceField(
-        label=_('Site group'),
-        queryset=SiteGroup.objects.all(),
-        required=False,
-    )
-    site = DynamicModelChoiceField(
-        label=_('Site'),
-        queryset=Site.objects.all(),
-        required=False,
-        query_params={
-            'region_id': '$region',
-            'group_id': '$site_group',
-        }
-    )
     description = forms.CharField(
         label=_('Description'),
         max_length=200,
@@ -106,10 +88,10 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
     model = Cluster
     fieldsets = (
         FieldSet('type', 'group', 'status', 'tenant', 'description'),
-        FieldSet('region', 'site_group', 'site', name=_('Site')),
+        FieldSet('scope_type', 'scope', name=_('Scope')),
     )
     nullable_fields = (
-        'group', 'site', 'tenant', 'description', 'comments',
+        'group', 'scope', 'tenant', 'description', 'comments',
     )
 
 

+ 6 - 2
netbox/virtualization/forms/bulk_import.py

@@ -1,6 +1,7 @@
 from django.utils.translation import gettext_lazy as _
 
 from dcim.choices import InterfaceModeChoices
+from dcim.forms.mixins import ScopedImportForm
 from dcim.models import Device, DeviceRole, Platform, Site
 from extras.models import ConfigTemplate
 from ipam.models import VRF
@@ -36,7 +37,7 @@ class ClusterGroupImportForm(NetBoxModelImportForm):
         fields = ('name', 'slug', 'description', 'tags')
 
 
-class ClusterImportForm(NetBoxModelImportForm):
+class ClusterImportForm(ScopedImportForm, NetBoxModelImportForm):
     type = CSVModelChoiceField(
         label=_('Type'),
         queryset=ClusterType.objects.all(),
@@ -72,7 +73,10 @@ class ClusterImportForm(NetBoxModelImportForm):
 
     class Meta:
         model = Cluster
-        fields = ('name', 'type', 'group', 'status', 'site', 'tenant', 'description', 'comments', 'tags')
+        fields = ('name', 'type', 'group', 'status', 'scope_type', 'scope_id', 'tenant', 'description', 'comments', 'tags')
+        labels = {
+            'scope_id': _('Scope ID'),
+        }
 
 
 class VirtualMachineImportForm(NetBoxModelImportForm):

+ 12 - 7
netbox/virtualization/forms/filtersets.py

@@ -1,7 +1,7 @@
 from django import forms
 from django.utils.translation import gettext_lazy as _
 
-from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, Location, Platform, Region, Site, SiteGroup
 from extras.forms import LocalConfigContextFilterForm
 from extras.models import ConfigTemplate
 from ipam.models import VRF
@@ -43,7 +43,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('group_id', 'type_id', 'status', name=_('Attributes')),
-        FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
+        FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')),
         FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
@@ -58,11 +58,6 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
         required=False,
         label=_('Region')
     )
-    status = forms.MultipleChoiceField(
-        label=_('Status'),
-        choices=ClusterStatusChoices,
-        required=False
-    )
     site_group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
@@ -78,6 +73,16 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
         },
         label=_('Site')
     )
+    location_id = DynamicModelMultipleChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        label=_('Location')
+    )
+    status = forms.MultipleChoiceField(
+        label=_('Status'),
+        choices=ClusterStatusChoices,
+        required=False
+    )
     group_id = DynamicModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),
         required=False,

+ 5 - 9
netbox/virtualization/forms/model_forms.py

@@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
 
 from dcim.forms.common import InterfaceCommonForm
+from dcim.forms.mixins import ScopedForm
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from extras.models import ConfigTemplate
 from ipam.choices import VLANQinQRoleChoices
@@ -58,7 +59,7 @@ class ClusterGroupForm(NetBoxModelForm):
         )
 
 
-class ClusterForm(TenancyForm, NetBoxModelForm):
+class ClusterForm(TenancyForm, ScopedForm, NetBoxModelForm):
     type = DynamicModelChoiceField(
         label=_('Type'),
         queryset=ClusterType.objects.all()
@@ -68,23 +69,18 @@ class ClusterForm(TenancyForm, NetBoxModelForm):
         queryset=ClusterGroup.objects.all(),
         required=False
     )
-    site = DynamicModelChoiceField(
-        label=_('Site'),
-        queryset=Site.objects.all(),
-        required=False,
-        selector=True
-    )
     comments = CommentField()
 
     fieldsets = (
-        FieldSet('name', 'type', 'group', 'site', 'status', 'description', 'tags', name=_('Cluster')),
+        FieldSet('name', 'type', 'group', 'status', 'description', 'tags', name=_('Cluster')),
+        FieldSet('scope_type', 'scope', name=_('Scope')),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
     )
 
     class Meta:
         model = Cluster
         fields = (
-            'name', 'type', 'group', 'status', 'tenant', 'site', 'description', 'comments', 'tags',
+            'name', 'type', 'group', 'status', 'tenant', 'scope_type', 'description', 'comments', 'tags',
         )
 
 

+ 11 - 4
netbox/virtualization/graphql/types.py

@@ -1,4 +1,4 @@
-from typing import Annotated, List
+from typing import Annotated, List, Union
 
 import strawberry
 import strawberry_django
@@ -31,18 +31,25 @@ class ComponentType(NetBoxObjectType):
 
 @strawberry_django.type(
     models.Cluster,
-    fields='__all__',
+    exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
     filters=ClusterFilter
 )
 class ClusterType(VLANGroupsMixin, NetBoxObjectType):
     type: Annotated["ClusterTypeType", strawberry.lazy('virtualization.graphql.types')] | None
     group: Annotated["ClusterGroupType", strawberry.lazy('virtualization.graphql.types')] | None
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
-    site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None
-
     virtual_machines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]]
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
 
+    @strawberry_django.field
+    def scope(self) -> Annotated[Union[
+        Annotated["LocationType", strawberry.lazy('dcim.graphql.types')],
+        Annotated["RegionType", strawberry.lazy('dcim.graphql.types')],
+        Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')],
+        Annotated["SiteType", strawberry.lazy('dcim.graphql.types')],
+    ], strawberry.union("ClusterScopeType")] | None:
+         return self.scope
+
 
 @strawberry_django.type(
     models.ClusterGroup,

+ 51 - 0
netbox/virtualization/migrations/0044_cluster_scope.py

@@ -0,0 +1,51 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+def copy_site_assignments(apps, schema_editor):
+     """
+     Copy site ForeignKey values to the scope GFK.
+     """
+     ContentType = apps.get_model('contenttypes', 'ContentType')
+     Cluster = apps.get_model('virtualization', 'Cluster')
+     Site = apps.get_model('dcim', 'Site')
+
+     Cluster.objects.filter(site__isnull=False).update(
+         scope_type=ContentType.objects.get_for_model(Site),
+         scope_id=models.F('site_id')
+     )
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('virtualization', '0043_qinq_svlan'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='cluster',
+            name='scope_id',
+            field=models.PositiveBigIntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='cluster',
+            name='scope_type',
+            field=models.ForeignKey(
+                blank=True,
+                limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location'))),
+                null=True,
+                on_delete=django.db.models.deletion.PROTECT,
+                related_name='+',
+                to='contenttypes.contenttype',
+            ),
+        ),
+
+        # Copy over existing site assignments
+        migrations.RunPython(
+            code=copy_site_assignments,
+            reverse_code=migrations.RunPython.noop
+        ),
+
+    ]

+ 94 - 0
netbox/virtualization/migrations/0045_clusters_cached_relations.py

@@ -0,0 +1,94 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+def populate_denormalized_fields(apps, schema_editor):
+    """
+    Copy the denormalized fields for _region, _site_group and _site from existing site field.
+    """
+    Cluster = apps.get_model('virtualization', 'Cluster')
+
+    clusters = Cluster.objects.filter(site__isnull=False).prefetch_related('site')
+    for cluster in clusters:
+        cluster._region_id = cluster.site.region_id
+        cluster._site_group_id = cluster.site.group_id
+        cluster._site_id = cluster.site_id
+        # Note: Location cannot be set prior to migration
+
+    Cluster.objects.bulk_update(clusters, ['_region', '_site_group', '_site'])
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('virtualization', '0044_cluster_scope'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='cluster',
+            name='_location',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name='_%(class)ss',
+                to='dcim.location',
+            ),
+        ),
+        migrations.AddField(
+            model_name='cluster',
+            name='_region',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name='_%(class)ss',
+                to='dcim.region',
+            ),
+        ),
+        migrations.AddField(
+            model_name='cluster',
+            name='_site',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name='_%(class)ss',
+                to='dcim.site',
+            ),
+        ),
+        migrations.AddField(
+            model_name='cluster',
+            name='_site_group',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name='_%(class)ss',
+                to='dcim.sitegroup',
+            ),
+        ),
+
+        # Populate denormalized FK values
+        migrations.RunPython(
+            code=populate_denormalized_fields,
+            reverse_code=migrations.RunPython.noop
+        ),
+
+        migrations.RemoveConstraint(
+            model_name='cluster',
+            name='virtualization_cluster_unique_site_name',
+        ),
+        # Delete the site ForeignKey
+        migrations.RemoveField(
+            model_name='cluster',
+            name='site',
+        ),
+        migrations.AddConstraint(
+            model_name='cluster',
+            constraint=models.UniqueConstraint(
+                fields=('_site', 'name'), name='virtualization_cluster_unique__site_name'
+            ),
+        ),
+    ]

+ 30 - 18
netbox/virtualization/models/clusters.py

@@ -1,9 +1,11 @@
+from django.apps import apps
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 
 from dcim.models import Device
+from dcim.models.mixins import CachedScopeMixin
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models.features import ContactsMixin
 from virtualization.choices import *
@@ -42,7 +44,7 @@ class ClusterGroup(ContactsMixin, OrganizationalModel):
         verbose_name_plural = _('cluster groups')
 
 
-class Cluster(ContactsMixin, PrimaryModel):
+class Cluster(ContactsMixin, CachedScopeMixin, PrimaryModel):
     """
     A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
     """
@@ -76,13 +78,6 @@ class Cluster(ContactsMixin, PrimaryModel):
         blank=True,
         null=True
     )
-    site = models.ForeignKey(
-        to='dcim.Site',
-        on_delete=models.PROTECT,
-        related_name='clusters',
-        blank=True,
-        null=True
-    )
 
     # Generic relations
     vlan_groups = GenericRelation(
@@ -93,7 +88,7 @@ class Cluster(ContactsMixin, PrimaryModel):
     )
 
     clone_fields = (
-        'type', 'group', 'status', 'tenant', 'site',
+        'scope_type', 'scope_id', 'type', 'group', 'status', 'tenant',
     )
     prerequisite_models = (
         'virtualization.ClusterType',
@@ -107,8 +102,8 @@ class Cluster(ContactsMixin, PrimaryModel):
                 name='%(app_label)s_%(class)s_unique_group_name'
             ),
             models.UniqueConstraint(
-                fields=('site', 'name'),
-                name='%(app_label)s_%(class)s_unique_site_name'
+                fields=('_site', 'name'),
+                name='%(app_label)s_%(class)s_unique__site_name'
             ),
         )
         verbose_name = _('cluster')
@@ -123,11 +118,28 @@ class Cluster(ContactsMixin, PrimaryModel):
     def clean(self):
         super().clean()
 
+        site = location = None
+        if self.scope_type:
+            scope_type = self.scope_type.model_class()
+            if scope_type == apps.get_model('dcim', 'site'):
+                site = self.scope
+            elif scope_type == apps.get_model('dcim', 'location'):
+                location = self.scope
+                site = location.site
+
         # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site.
-        if not self._state.adding and self.site:
-            if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=self.site).count():
-                raise ValidationError({
-                    'site': _(
-                        "{count} devices are assigned as hosts for this cluster but are not in site {site}"
-                    ).format(count=nonsite_devices, site=self.site)
-                })
+        if not self._state.adding:
+            if site:
+                if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=site).count():
+                    raise ValidationError({
+                        'scope': _(
+                            "{count} devices are assigned as hosts for this cluster but are not in site {site}"
+                        ).format(count=nonsite_devices, site=site)
+                    })
+            if location:
+                if nonlocation_devices := Device.objects.filter(cluster=self).exclude(location=location).count():
+                    raise ValidationError({
+                        'scope': _(
+                            "{count} devices are assigned as hosts for this cluster but are not in location {location}"
+                        ).format(count=nonlocation_devices, location=location)
+                    })

+ 2 - 2
netbox/virtualization/models/virtualmachines.py

@@ -181,7 +181,7 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
             })
 
         # Validate site for cluster & VM
-        if self.cluster and self.site and self.cluster.site and self.cluster.site != self.site:
+        if self.cluster and self.site and self.cluster._site and self.cluster._site != self.site:
             raise ValidationError({
                 'cluster': _(
                     'The selected cluster ({cluster}) is not assigned to this site ({site}).'
@@ -238,7 +238,7 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
 
         # Assign site from cluster if not set
         if self.cluster and not self.site:
-            self.site = self.cluster.site
+            self.site = self.cluster._site
 
         super().save(*args, **kwargs)
 

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

@@ -73,8 +73,11 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     status = columns.ChoiceFieldColumn(
         verbose_name=_('Status'),
     )
-    site = tables.Column(
-        verbose_name=_('Site'),
+    scope_type = columns.ContentTypeColumn(
+        verbose_name=_('Scope Type'),
+    )
+    scope = tables.Column(
+        verbose_name=_('Scope'),
         linkify=True
     )
     device_count = columns.LinkedCountColumn(
@@ -97,7 +100,7 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = Cluster
         fields = (
-            'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'site', 'description', 'comments',
-            'device_count', 'vm_count', 'contacts', 'tags', 'created', 'last_updated',
+            'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'scope', 'scope_type', 'description',
+            'comments', 'device_count', 'vm_count', 'contacts', 'tags', 'created', 'last_updated',
         )
         default_columns = ('pk', 'name', 'type', 'group', 'status', 'tenant', 'site', 'device_count', 'vm_count')

+ 6 - 4
netbox/virtualization/tests/test_api.py

@@ -113,7 +113,8 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
             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)
+        for cluster in clusters:
+            cluster.save()
 
         cls.create_data = [
             {
@@ -157,11 +158,12 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
         Site.objects.bulk_create(sites)
 
         clusters = (
-            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 1', type=clustertype, scope=sites[0], group=clustergroup),
+            Cluster(name='Cluster 2', type=clustertype, scope=sites[1], group=clustergroup),
             Cluster(name='Cluster 3', type=clustertype),
         )
-        Cluster.objects.bulk_create(clusters)
+        for cluster in clusters:
+            cluster.save()
 
         device1 = create_test_device('device1', site=sites[0], cluster=clusters[0])
         device2 = create_test_device('device2', site=sites[1], cluster=clusters[1])

+ 10 - 8
netbox/virtualization/tests/test_filtersets.py

@@ -138,7 +138,7 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
                 type=cluster_types[0],
                 group=cluster_groups[0],
                 status=ClusterStatusChoices.STATUS_PLANNED,
-                site=sites[0],
+                scope=sites[0],
                 tenant=tenants[0],
                 description='foobar1'
             ),
@@ -147,7 +147,7 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
                 type=cluster_types[1],
                 group=cluster_groups[1],
                 status=ClusterStatusChoices.STATUS_STAGING,
-                site=sites[1],
+                scope=sites[1],
                 tenant=tenants[1],
                 description='foobar2'
             ),
@@ -156,12 +156,13 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
                 type=cluster_types[2],
                 group=cluster_groups[2],
                 status=ClusterStatusChoices.STATUS_ACTIVE,
-                site=sites[2],
+                scope=sites[2],
                 tenant=tenants[2],
                 description='foobar3'
             ),
         )
-        Cluster.objects.bulk_create(clusters)
+        for cluster in clusters:
+            cluster.save()
 
     def test_q(self):
         params = {'q': 'foobar1'}
@@ -274,11 +275,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
         Site.objects.bulk_create(sites)
 
         clusters = (
-            Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], site=sites[0]),
-            Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], site=sites[1]),
-            Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], site=sites[2]),
+            Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], scope=sites[0]),
+            Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], scope=sites[1]),
+            Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], scope=sites[2]),
         )
-        Cluster.objects.bulk_create(clusters)
+        for cluster in clusters:
+            cluster.save()
 
         platforms = (
             Platform(name='Platform 1', slug='platform-1'),

+ 5 - 4
netbox/virtualization/tests/test_models.py

@@ -54,11 +54,12 @@ class VirtualMachineTestCase(TestCase):
         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(name='Cluster 1', type=cluster_type, scope=sites[0]),
+            Cluster(name='Cluster 2', type=cluster_type, scope=sites[1]),
+            Cluster(name='Cluster 3', type=cluster_type, scope=None),
         )
-        Cluster.objects.bulk_create(clusters)
+        for cluster in clusters:
+            cluster.save()
 
         # VM with site only should pass
         VirtualMachine(name='vm1', site=sites[0]).full_clean()

+ 13 - 10
netbox/virtualization/tests/test_views.py

@@ -1,3 +1,4 @@
+from django.contrib.contenttypes.models import ContentType
 from django.test import override_settings
 from django.urls import reverse
 from netaddr import EUI
@@ -117,11 +118,12 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         ClusterType.objects.bulk_create(clustertypes)
 
         clusters = (
-            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]),
+            Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, scope=sites[0]),
+            Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, scope=sites[0]),
+            Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, scope=sites[0]),
         )
-        Cluster.objects.bulk_create(clusters)
+        for cluster in clusters:
+            cluster.save()
 
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
@@ -131,7 +133,8 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'type': clustertypes[1].pk,
             'status': ClusterStatusChoices.STATUS_OFFLINE,
             'tenant': None,
-            'site': sites[1].pk,
+            'scope_type': ContentType.objects.get_for_model(Site).pk,
+            'scope': sites[1].pk,
             'comments': 'Some comments',
             'tags': [t.pk for t in tags],
         }
@@ -155,7 +158,6 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'type': clustertypes[1].pk,
             'status': ClusterStatusChoices.STATUS_OFFLINE,
             'tenant': None,
-            'site': sites[1].pk,
             'comments': 'New comments',
         }
 
@@ -201,10 +203,11 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
 
         clusters = (
-            Cluster(name='Cluster 1', type=clustertype, site=sites[0]),
-            Cluster(name='Cluster 2', type=clustertype, site=sites[1]),
+            Cluster(name='Cluster 1', type=clustertype, scope=sites[0]),
+            Cluster(name='Cluster 2', type=clustertype, scope=sites[1]),
         )
-        Cluster.objects.bulk_create(clusters)
+        for cluster in clusters:
+            cluster.save()
 
         devices = (
             create_test_device('device1', site=sites[0], cluster=clusters[0]),
@@ -292,7 +295,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         site = Site.objects.create(name='Site 1', slug='site-1')
         role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-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, scope=site)
         virtualmachines = (
             VirtualMachine(name='Virtual Machine 1', site=site, cluster=cluster, role=role),
             VirtualMachine(name='Virtual Machine 2', site=site, cluster=cluster, role=role),