diff --git a/docs/models/virtualization/cluster.md b/docs/models/virtualization/cluster.md
index 50b5dbd1d..9acdb2bc4 100644
--- a/docs/models/virtualization/cluster.md
+++ b/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.
diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py
index ba3e6464b..4927b0198 100644
--- a/netbox/dcim/constants.py
+++ b/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',
+)
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index 0371f882b..df66ad77b 100644
--- a/netbox/dcim/filtersets.py
+++ b/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)'),
+ )
diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py
new file mode 100644
index 000000000..98862af10
--- /dev/null
+++ b/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)')
+ )
diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py
index 5965fdcec..6493ec6b1 100644
--- a/netbox/dcim/graphql/types.py
+++ b/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()
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index b9ba2bb64..47f4ee6c9 100644
--- a/netbox/dcim/models/devices.py
+++ b/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
)
})
diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py
index c9be451a0..1df3364c4 100644
--- a/netbox/dcim/models/mixins.py
+++ b/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
diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py
index f0fe4da3b..8d43d67ea 100644
--- a/netbox/dcim/tests/test_models.py
+++ b/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()
diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py
index 188a06a3f..c90390dd1 100644
--- a/netbox/extras/tests/test_models.py
+++ b/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
diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py
index 771e9b3b9..77ab8194a 100644
--- a/netbox/ipam/querysets.py
+++ b/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(
diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py
index f651c970d..28e8cda1e 100644
--- a/netbox/ipam/tests/test_filtersets.py
+++ b/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]),
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html
index d79d8075c..4155dacb2 100644
--- a/netbox/templates/virtualization/cluster.html
+++ b/netbox/templates/virtualization/cluster.html
@@ -39,8 +39,12 @@
- {% trans "Site" %} |
- {{ object.site|linkify|placeholder }} |
+ {% trans "Scope" %} |
+ {% if object.scope %}
+ {{ object.scope|linkify }} ({% trans object.scope_type.name %}) |
+ {% else %}
+ {{ ''|placeholder }} |
+ {% endif %}
diff --git a/netbox/virtualization/api/serializers_/clusters.py b/netbox/virtualization/api/serializers_/clusters.py
index b64b6e7ba..101a5b5a3 100644
--- a/netbox/virtualization/api/serializers_/clusters.py
+++ b/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
+
+
diff --git a/netbox/virtualization/apps.py b/netbox/virtualization/apps.py
index ebcc591bf..65ce0f112 100644
--- a/netbox/virtualization/apps.py
+++ b/netbox/virtualization/apps.py
@@ -17,7 +17,7 @@ class VirtualizationConfig(AppConfig):
# Register denormalized fields
denormalized.register(VirtualMachine, 'cluster', {
- 'site': 'site',
+ 'site': '_site',
})
# Register counters
diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py
index ec0831f9f..ac72bea12 100644
--- a/netbox/virtualization/filtersets.py
+++ b/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():
diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py
index 2bd3434ac..aaeb259b9 100644
--- a/netbox/virtualization/forms/bulk_edit.py
+++ b/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',
)
diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py
index 17efc567a..9ccdd68f7 100644
--- a/netbox/virtualization/forms/bulk_import.py
+++ b/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):
diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py
index 7c040d948..695641e4e 100644
--- a/netbox/virtualization/forms/filtersets.py
+++ b/netbox/virtualization/forms/filtersets.py
@@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext_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,
diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py
index 4527e7f4c..44c67d389 100644
--- a/netbox/virtualization/forms/model_forms.py
+++ b/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',
)
diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py
index 79b5cb216..f51e0e3f5 100644
--- a/netbox/virtualization/graphql/types.py
+++ b/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,
diff --git a/netbox/virtualization/migrations/0044_cluster_scope.py b/netbox/virtualization/migrations/0044_cluster_scope.py
new file mode 100644
index 000000000..63a888ac3
--- /dev/null
+++ b/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
+ ),
+
+ ]
diff --git a/netbox/virtualization/migrations/0045_clusters_cached_relations.py b/netbox/virtualization/migrations/0045_clusters_cached_relations.py
new file mode 100644
index 000000000..ff851aa7c
--- /dev/null
+++ b/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'
+ ),
+ ),
+ ]
diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py
index b8921c603..601ee7f23 100644
--- a/netbox/virtualization/models/clusters.py
+++ b/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)
+ })
diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py
index da8419e88..4ee41e403 100644
--- a/netbox/virtualization/models/virtualmachines.py
+++ b/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)
diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py
index d3c799fb9..91807e35b 100644
--- a/netbox/virtualization/tables/clusters.py
+++ b/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')
diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py
index 521064fc6..149b64684 100644
--- a/netbox/virtualization/tests/test_api.py
+++ b/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])
diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py
index 0c7079bba..5a5bf2325 100644
--- a/netbox/virtualization/tests/test_filtersets.py
+++ b/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'),
diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py
index a4e8d7947..7be423bf1 100644
--- a/netbox/virtualization/tests/test_models.py
+++ b/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()
diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py
index 3c6a058c9..b9cb7b437 100644
--- a/netbox/virtualization/tests/test_views.py
+++ b/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),