mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
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:
parent
8767fd8186
commit
6dc75d8db1
@ -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.
|
||||||
|
@ -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',
|
||||||
|
)
|
||||||
|
@ -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
105
netbox/dcim/forms/mixins.py
Normal 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)')
|
||||||
|
)
|
@ -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()
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
@ -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]),
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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():
|
||||||
|
@ -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',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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,
|
||||||
|
@ -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',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from typing import Annotated, List
|
from typing import Annotated, List, Union
|
||||||
|
|
||||||
import strawberry
|
import strawberry
|
||||||
import strawberry_django
|
import strawberry_django
|
||||||
@ -31,18 +31,25 @@ class ComponentType(NetBoxObjectType):
|
|||||||
|
|
||||||
@strawberry_django.type(
|
@strawberry_django.type(
|
||||||
models.Cluster,
|
models.Cluster,
|
||||||
fields='__all__',
|
exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'),
|
||||||
filters=ClusterFilter
|
filters=ClusterFilter
|
||||||
)
|
)
|
||||||
class ClusterType(VLANGroupsMixin, NetBoxObjectType):
|
class ClusterType(VLANGroupsMixin, NetBoxObjectType):
|
||||||
type: Annotated["ClusterTypeType", strawberry.lazy('virtualization.graphql.types')] | None
|
type: Annotated["ClusterTypeType", strawberry.lazy('virtualization.graphql.types')] | None
|
||||||
group: Annotated["ClusterGroupType", strawberry.lazy('virtualization.graphql.types')] | None
|
group: Annotated["ClusterGroupType", strawberry.lazy('virtualization.graphql.types')] | None
|
||||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||||
site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None
|
|
||||||
|
|
||||||
virtual_machines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]]
|
virtual_machines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]]
|
||||||
devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
|
devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
|
||||||
|
|
||||||
|
@strawberry_django.field
|
||||||
|
def scope(self) -> Annotated[Union[
|
||||||
|
Annotated["LocationType", strawberry.lazy('dcim.graphql.types')],
|
||||||
|
Annotated["RegionType", strawberry.lazy('dcim.graphql.types')],
|
||||||
|
Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')],
|
||||||
|
Annotated["SiteType", strawberry.lazy('dcim.graphql.types')],
|
||||||
|
], strawberry.union("ClusterScopeType")] | None:
|
||||||
|
return self.scope
|
||||||
|
|
||||||
|
|
||||||
@strawberry_django.type(
|
@strawberry_django.type(
|
||||||
models.ClusterGroup,
|
models.ClusterGroup,
|
||||||
|
51
netbox/virtualization/migrations/0044_cluster_scope.py
Normal file
51
netbox/virtualization/migrations/0044_cluster_scope.py
Normal 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
|
||||||
|
),
|
||||||
|
|
||||||
|
]
|
@ -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'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -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)
|
||||||
})
|
})
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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')
|
||||||
|
@ -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])
|
||||||
|
@ -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'),
|
||||||
|
@ -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()
|
||||||
|
@ -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),
|
||||||
|
Loading…
Reference in New Issue
Block a user