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>
This commit is contained in:
Arthur Hanson 2024-11-01 11:18:23 -07:00 committed by GitHub
parent 8767fd8186
commit 6dc75d8db1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 588 additions and 156 deletions

View File

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

View File

@ -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',
)

View File

@ -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
netbox/dcim/forms/mixins.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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 1', type=cluster_type, scope=sites[0]),
Cluster(name='Cluster 2', type=cluster_type, site=sites[1]), Cluster(name='Cluster 2', type=cluster_type, scope=sites[1]),
Cluster(name='Cluster 3', type=cluster_type, site=None), 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()

View File

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

View File

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

View File

@ -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 1', type=cluster_type, group=cluster_groups[0], scope=sites[0]),
Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1], site=sites[1]), 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], site=sites[2]), 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]),

View File

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

View File

@ -1,9 +1,13 @@
from dcim.api.serializers_.sites import SiteSerializer from dcim.constants import LOCATION_SCOPE_TYPES
from netbox.api.fields import ChoiceField, RelatedObjectCountField 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

View File

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

View File

@ -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): class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ScopedFilterSet, 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)'),
)
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():

View File

@ -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',
) )

View File

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

View File

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

View File

@ -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',
) )

View File

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

View File

@ -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
),
]

View File

@ -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'
),
),
]

View File

@ -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'), fields=('_site', 'name'),
name='%(app_label)s_%(class)s_unique_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 not self._state.adding:
if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=self.site).count(): if site:
if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=site).count():
raise ValidationError({ raise ValidationError({
'site': _( 'scope': _(
"{count} devices are assigned as hosts for this cluster but are not in site {site}" "{count} devices are assigned as hosts for this cluster but are not in site {site}"
).format(count=nonsite_devices, site=self.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)
}) })

View File

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

View File

@ -73,8 +73,11 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
status = columns.ChoiceFieldColumn( status = columns.ChoiceFieldColumn(
verbose_name=_('Status'), verbose_name=_('Status'),
) )
site = tables.Column( scope_type = columns.ContentTypeColumn(
verbose_name=_('Site'), 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', 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'scope', 'scope_type', 'description',
'device_count', 'vm_count', 'contacts', 'tags', 'created', 'last_updated', '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')

View File

@ -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 1', type=clustertype, scope=sites[0], group=clustergroup),
Cluster(name='Cluster 2', type=clustertype, site=sites[1], 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])

View File

@ -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 1', type=cluster_types[0], group=cluster_groups[0], scope=sites[0]),
Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], site=sites[1]), 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], site=sites[2]), 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'),

View File

@ -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 1', type=cluster_type, scope=sites[0]),
Cluster(name='Cluster 2', type=cluster_type, site=sites[1]), Cluster(name='Cluster 2', type=cluster_type, scope=sites[1]),
Cluster(name='Cluster 3', type=cluster_type, site=None), 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()

View File

@ -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 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, site=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, site=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 1', type=clustertype, scope=sites[0]),
Cluster(name='Cluster 2', type=clustertype, site=sites[1]), 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),