Ver Fonte

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 há 1 ano atrás
pai
commit
6dc75d8db1

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

@@ -23,6 +23,6 @@ The cluster's operational status.
 !!! tip
 !!! tip
     Additional statuses may be defined by setting `Cluster.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
     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'],
     'powerport': ['poweroutlet', 'powerfeed'],
     'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
     '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',
     'RearPortFilterSet',
     'RearPortTemplateFilterSet',
     'RearPortTemplateFilterSet',
     'RegionFilterSet',
     'RegionFilterSet',
+    'ScopedFilterSet',
     'SiteFilterSet',
     'SiteFilterSet',
     'SiteGroupFilterSet',
     'SiteGroupFilterSet',
     'VirtualChassisFilterSet',
     'VirtualChassisFilterSet',
@@ -2344,3 +2345,60 @@ class InterfaceConnectionFilterSet(ConnectionFilterSet):
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = tuple()
         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')]]
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     children: List[Annotated["LocationType", 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
     @strawberry_django.field
     def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
     def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
         return self.circuit_terminations.all()
         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:
     def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         return self.parent
 
 
+    @strawberry_django.field
+    def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
+        return self._clusters.all()
+
     @strawberry_django.field
     @strawberry_django.field
     def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
     def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
         return self.circuit_terminations.all()
         return self.circuit_terminations.all()
@@ -735,9 +743,14 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
     locations: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]]
     locations: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]]
     asns: List[Annotated["ASNType", strawberry.lazy('ipam.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')]]
     clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]
     vlans: List[Annotated["VLANType", strawberry.lazy('ipam.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
     @strawberry_django.field
     def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
     def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
         return self.circuit_terminations.all()
         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:
     def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None:
         return self.parent
         return self.parent
 
 
+    @strawberry_django.field
+    def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
+        return self._clusters.all()
+
     @strawberry_django.field
     @strawberry_django.field
     def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
     def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]:
         return self.circuit_terminations.all()
         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)
         # 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({
             raise ValidationError({
                 'cluster': _("The assigned cluster belongs to a different site ({site})").format(
                 '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 django.db import models
+from dcim.constants import LOCATION_SCOPE_TYPES
 
 
 __all__ = (
 __all__ = (
+    'CachedScopeMixin',
     'RenderConfigMixin',
     'RenderConfigMixin',
 )
 )
 
 
@@ -27,3 +31,84 @@ class RenderConfigMixin(models.Model):
             return self.role.config_template
             return self.role.config_template
         if self.platform and self.platform.config_template:
         if self.platform and self.platform.config_template:
             return 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)
         Site.objects.bulk_create(sites)
 
 
         clusters = (
         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_type = DeviceType.objects.first()
         device_role = DeviceRole.objects.first()
         device_role = DeviceRole.objects.first()

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

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

+ 1 - 1
netbox/ipam/querysets.py

@@ -148,7 +148,7 @@ class VLANQuerySet(RestrictedQuerySet):
 
 
         # Find all relevant VLANGroups
         # Find all relevant VLANGroups
         q = Q()
         q = Q()
-        site = vm.site or vm.cluster.site
+        site = vm.site or vm.cluster._site
         if vm.cluster:
         if vm.cluster:
             # Add VLANGroups scoped to the assigned cluster (or its group)
             # Add VLANGroups scoped to the assigned cluster (or its group)
             q |= Q(
             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')
         cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
         clusters = (
         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 = (
         virtual_machines = (
             VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]),
             VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]),

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

@@ -39,8 +39,12 @@
             </td>
             </td>
         </tr>
         </tr>
         <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>
         </tr>
       </table>
       </table>
     </div>
     </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 netbox.api.serializers import NetBoxModelSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer
 from tenancy.api.serializers_.tenants import TenantSerializer
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
+from utilities.api import get_serializer_for_model
 
 
 __all__ = (
 __all__ = (
     'ClusterGroupSerializer',
     'ClusterGroupSerializer',
@@ -45,7 +49,16 @@ class ClusterSerializer(NetBoxModelSerializer):
     group = ClusterGroupSerializer(nested=True, required=False, allow_null=True, default=None)
     group = ClusterGroupSerializer(nested=True, required=False, allow_null=True, default=None)
     status = ChoiceField(choices=ClusterStatusChoices, required=False)
     status = ChoiceField(choices=ClusterStatusChoices, required=False)
     tenant = TenantSerializer(nested=True, required=False, allow_null=True)
     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
     # Related object counts
     device_count = RelatedObjectCountField('devices')
     device_count = RelatedObjectCountField('devices')
@@ -54,8 +67,18 @@ class ClusterSerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
         fields = [
         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',
             'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
             'virtualmachine_count',
             'virtualmachine_count',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description', '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
         # Register denormalized fields
         denormalized.register(VirtualMachine, 'cluster', {
         denormalized.register(VirtualMachine, 'cluster', {
-            'site': 'site',
+            'site': '_site',
         })
         })
 
 
         # Register counters
         # Register counters

+ 3 - 39
netbox/virtualization/filtersets.py

@@ -2,7 +2,7 @@ import django_filters
 from django.db.models import Q
 from django.db.models import Q
 from django.utils.translation import gettext as _
 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 dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
@@ -37,43 +37,7 @@ class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet)
         fields = ('id', 'name', 'slug', 'description')
         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(
     group_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         label=_('Parent group (ID)'),
         label=_('Parent group (ID)'),
@@ -101,7 +65,7 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
-        fields = ('id', 'name', 'description')
+        fields = ('id', 'name', 'description', 'scope_id')
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):
         if not value.strip():
         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.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 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 extras.models import ConfigTemplate
 from ipam.models import VLAN, VLANGroup, VRF
 from ipam.models import VLAN, VLANGroup, VRF
 from netbox.forms import NetBoxModelBulkEditForm
 from netbox.forms import NetBoxModelBulkEditForm
@@ -55,7 +56,7 @@ class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm):
     nullable_fields = ('description',)
     nullable_fields = ('description',)
 
 
 
 
-class ClusterBulkEditForm(NetBoxModelBulkEditForm):
+class ClusterBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm):
     type = DynamicModelChoiceField(
     type = DynamicModelChoiceField(
         label=_('Type'),
         label=_('Type'),
         queryset=ClusterType.objects.all(),
         queryset=ClusterType.objects.all(),
@@ -77,25 +78,6 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
         queryset=Tenant.objects.all(),
         queryset=Tenant.objects.all(),
         required=False
         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(
     description = forms.CharField(
         label=_('Description'),
         label=_('Description'),
         max_length=200,
         max_length=200,
@@ -106,10 +88,10 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
     model = Cluster
     model = Cluster
     fieldsets = (
     fieldsets = (
         FieldSet('type', 'group', 'status', 'tenant', 'description'),
         FieldSet('type', 'group', 'status', 'tenant', 'description'),
-        FieldSet('region', 'site_group', 'site', name=_('Site')),
+        FieldSet('scope_type', 'scope', name=_('Scope')),
     )
     )
     nullable_fields = (
     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 django.utils.translation import gettext_lazy as _
 
 
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
+from dcim.forms.mixins import ScopedImportForm
 from dcim.models import Device, DeviceRole, Platform, Site
 from dcim.models import Device, DeviceRole, Platform, Site
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
 from ipam.models import VRF
 from ipam.models import VRF
@@ -36,7 +37,7 @@ class ClusterGroupImportForm(NetBoxModelImportForm):
         fields = ('name', 'slug', 'description', 'tags')
         fields = ('name', 'slug', 'description', 'tags')
 
 
 
 
-class ClusterImportForm(NetBoxModelImportForm):
+class ClusterImportForm(ScopedImportForm, NetBoxModelImportForm):
     type = CSVModelChoiceField(
     type = CSVModelChoiceField(
         label=_('Type'),
         label=_('Type'),
         queryset=ClusterType.objects.all(),
         queryset=ClusterType.objects.all(),
@@ -72,7 +73,10 @@ class ClusterImportForm(NetBoxModelImportForm):
 
 
     class Meta:
     class Meta:
         model = Cluster
         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):
 class VirtualMachineImportForm(NetBoxModelImportForm):

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

@@ -1,7 +1,7 @@
 from django import forms
 from django import forms
 from django.utils.translation import gettext_lazy as _
 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.forms import LocalConfigContextFilterForm
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
 from ipam.models import VRF
 from ipam.models import VRF
@@ -43,7 +43,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('group_id', 'type_id', 'status', name=_('Attributes')),
         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('tenant_group_id', 'tenant_id', name=_('Tenant')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
         FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
     )
     )
@@ -58,11 +58,6 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
         required=False,
         required=False,
         label=_('Region')
         label=_('Region')
     )
     )
-    status = forms.MultipleChoiceField(
-        label=_('Status'),
-        choices=ClusterStatusChoices,
-        required=False
-    )
     site_group_id = DynamicModelMultipleChoiceField(
     site_group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         queryset=SiteGroup.objects.all(),
         required=False,
         required=False,
@@ -78,6 +73,16 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
         },
         },
         label=_('Site')
         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(
     group_id = DynamicModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         required=False,
         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 django.utils.translation import gettext_lazy as _
 
 
 from dcim.forms.common import InterfaceCommonForm
 from dcim.forms.common import InterfaceCommonForm
+from dcim.forms.mixins import ScopedForm
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
 from ipam.choices import VLANQinQRoleChoices
 from ipam.choices import VLANQinQRoleChoices
@@ -58,7 +59,7 @@ class ClusterGroupForm(NetBoxModelForm):
         )
         )
 
 
 
 
-class ClusterForm(TenancyForm, NetBoxModelForm):
+class ClusterForm(TenancyForm, ScopedForm, NetBoxModelForm):
     type = DynamicModelChoiceField(
     type = DynamicModelChoiceField(
         label=_('Type'),
         label=_('Type'),
         queryset=ClusterType.objects.all()
         queryset=ClusterType.objects.all()
@@ -68,23 +69,18 @@ class ClusterForm(TenancyForm, NetBoxModelForm):
         queryset=ClusterGroup.objects.all(),
         queryset=ClusterGroup.objects.all(),
         required=False
         required=False
     )
     )
-    site = DynamicModelChoiceField(
-        label=_('Site'),
-        queryset=Site.objects.all(),
-        required=False,
-        selector=True
-    )
     comments = CommentField()
     comments = CommentField()
 
 
     fieldsets = (
     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')),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
     )
     )
 
 
     class Meta:
     class Meta:
         model = Cluster
         model = Cluster
         fields = (
         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
 import strawberry_django
 import strawberry_django
@@ -31,18 +31,25 @@ class ComponentType(NetBoxObjectType):
 
 
 @strawberry_django.type(
 @strawberry_django.type(
     models.Cluster,
     models.Cluster,
-    fields='__all__',
+    exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
     filters=ClusterFilter
     filters=ClusterFilter
 )
 )
 class ClusterType(VLANGroupsMixin, NetBoxObjectType):
 class ClusterType(VLANGroupsMixin, NetBoxObjectType):
     type: Annotated["ClusterTypeType", strawberry.lazy('virtualization.graphql.types')] | None
     type: Annotated["ClusterTypeType", strawberry.lazy('virtualization.graphql.types')] | None
     group: Annotated["ClusterGroupType", strawberry.lazy('virtualization.graphql.types')] | None
     group: Annotated["ClusterGroupType", strawberry.lazy('virtualization.graphql.types')] | None
     tenant: Annotated["TenantType", strawberry.lazy('tenancy.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')]]
     virtual_machines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]]
     devices: List[Annotated["DeviceType", strawberry.lazy('dcim.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(
 @strawberry_django.type(
     models.ClusterGroup,
     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.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
 from dcim.models import Device
 from dcim.models import Device
+from dcim.models.mixins import CachedScopeMixin
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models.features import ContactsMixin
 from netbox.models.features import ContactsMixin
 from virtualization.choices import *
 from virtualization.choices import *
@@ -42,7 +44,7 @@ class ClusterGroup(ContactsMixin, OrganizationalModel):
         verbose_name_plural = _('cluster groups')
         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.
     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,
         blank=True,
         null=True
         null=True
     )
     )
-    site = models.ForeignKey(
-        to='dcim.Site',
-        on_delete=models.PROTECT,
-        related_name='clusters',
-        blank=True,
-        null=True
-    )
 
 
     # Generic relations
     # Generic relations
     vlan_groups = GenericRelation(
     vlan_groups = GenericRelation(
@@ -93,7 +88,7 @@ class Cluster(ContactsMixin, PrimaryModel):
     )
     )
 
 
     clone_fields = (
     clone_fields = (
-        'type', 'group', 'status', 'tenant', 'site',
+        'scope_type', 'scope_id', 'type', 'group', 'status', 'tenant',
     )
     )
     prerequisite_models = (
     prerequisite_models = (
         'virtualization.ClusterType',
         'virtualization.ClusterType',
@@ -107,8 +102,8 @@ class Cluster(ContactsMixin, PrimaryModel):
                 name='%(app_label)s_%(class)s_unique_group_name'
                 name='%(app_label)s_%(class)s_unique_group_name'
             ),
             ),
             models.UniqueConstraint(
             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')
         verbose_name = _('cluster')
@@ -123,11 +118,28 @@ class Cluster(ContactsMixin, PrimaryModel):
     def clean(self):
     def clean(self):
         super().clean()
         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 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
         # 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({
             raise ValidationError({
                 'cluster': _(
                 'cluster': _(
                     'The selected cluster ({cluster}) is not assigned to this site ({site}).'
                     '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
         # Assign site from cluster if not set
         if self.cluster and not self.site:
         if self.cluster and not self.site:
-            self.site = self.cluster.site
+            self.site = self.cluster._site
 
 
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 

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

@@ -73,8 +73,11 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     status = columns.ChoiceFieldColumn(
     status = columns.ChoiceFieldColumn(
         verbose_name=_('Status'),
         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
         linkify=True
     )
     )
     device_count = columns.LinkedCountColumn(
     device_count = columns.LinkedCountColumn(
@@ -97,7 +100,7 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = Cluster
         model = Cluster
         fields = (
         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')
         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 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(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 = [
         cls.create_data = [
             {
             {
@@ -157,11 +158,12 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
         clusters = (
         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(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])
         device1 = create_test_device('device1', site=sites[0], cluster=clusters[0])
         device2 = create_test_device('device2', site=sites[1], cluster=clusters[1])
         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],
                 type=cluster_types[0],
                 group=cluster_groups[0],
                 group=cluster_groups[0],
                 status=ClusterStatusChoices.STATUS_PLANNED,
                 status=ClusterStatusChoices.STATUS_PLANNED,
-                site=sites[0],
+                scope=sites[0],
                 tenant=tenants[0],
                 tenant=tenants[0],
                 description='foobar1'
                 description='foobar1'
             ),
             ),
@@ -147,7 +147,7 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
                 type=cluster_types[1],
                 type=cluster_types[1],
                 group=cluster_groups[1],
                 group=cluster_groups[1],
                 status=ClusterStatusChoices.STATUS_STAGING,
                 status=ClusterStatusChoices.STATUS_STAGING,
-                site=sites[1],
+                scope=sites[1],
                 tenant=tenants[1],
                 tenant=tenants[1],
                 description='foobar2'
                 description='foobar2'
             ),
             ),
@@ -156,12 +156,13 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
                 type=cluster_types[2],
                 type=cluster_types[2],
                 group=cluster_groups[2],
                 group=cluster_groups[2],
                 status=ClusterStatusChoices.STATUS_ACTIVE,
                 status=ClusterStatusChoices.STATUS_ACTIVE,
-                site=sites[2],
+                scope=sites[2],
                 tenant=tenants[2],
                 tenant=tenants[2],
                 description='foobar3'
                 description='foobar3'
             ),
             ),
         )
         )
-        Cluster.objects.bulk_create(clusters)
+        for cluster in clusters:
+            cluster.save()
 
 
     def test_q(self):
     def test_q(self):
         params = {'q': 'foobar1'}
         params = {'q': 'foobar1'}
@@ -274,11 +275,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
         Site.objects.bulk_create(sites)
         Site.objects.bulk_create(sites)
 
 
         clusters = (
         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 = (
         platforms = (
             Platform(name='Platform 1', slug='platform-1'),
             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)
         Site.objects.bulk_create(sites)
 
 
         clusters = (
         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
         # VM with site only should pass
         VirtualMachine(name='vm1', site=sites[0]).full_clean()
         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.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
 from netaddr import EUI
 from netaddr import EUI
@@ -117,11 +118,12 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         ClusterType.objects.bulk_create(clustertypes)
         ClusterType.objects.bulk_create(clustertypes)
 
 
         clusters = (
         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')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
@@ -131,7 +133,8 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'type': clustertypes[1].pk,
             'type': clustertypes[1].pk,
             'status': ClusterStatusChoices.STATUS_OFFLINE,
             'status': ClusterStatusChoices.STATUS_OFFLINE,
             'tenant': None,
             'tenant': None,
-            'site': sites[1].pk,
+            'scope_type': ContentType.objects.get_for_model(Site).pk,
+            'scope': sites[1].pk,
             'comments': 'Some comments',
             'comments': 'Some comments',
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
         }
         }
@@ -155,7 +158,6 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'type': clustertypes[1].pk,
             'type': clustertypes[1].pk,
             'status': ClusterStatusChoices.STATUS_OFFLINE,
             'status': ClusterStatusChoices.STATUS_OFFLINE,
             'tenant': None,
             'tenant': None,
-            'site': sites[1].pk,
             'comments': 'New comments',
             'comments': 'New comments',
         }
         }
 
 
@@ -201,10 +203,11 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         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, 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 = (
         devices = (
             create_test_device('device1', site=sites[0], cluster=clusters[0]),
             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')
         site = Site.objects.create(name='Site 1', slug='site-1')
         role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-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')
         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 = (
         virtualmachines = (
             VirtualMachine(name='Virtual Machine 1', site=site, cluster=cluster, role=role),
             VirtualMachine(name='Virtual Machine 1', site=site, cluster=cluster, role=role),
             VirtualMachine(name='Virtual Machine 2', site=site, cluster=cluster, role=role),
             VirtualMachine(name='Virtual Machine 2', site=site, cluster=cluster, role=role),