Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

7699 Add Scope to Cluster #17848

Open
wants to merge 32 commits into
base: feature
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2bb49d7
7699 Add Scope to Cluster
arthanson Oct 23, 2024
dcb3c7c
7699 Serializer
arthanson Oct 23, 2024
33b4beb
7699 filterset
arthanson Oct 23, 2024
286f56b
7699 bulk_edit
arthanson Oct 23, 2024
4c3d1ce
7699 bulk_import
arthanson Oct 23, 2024
d19cef4
7699 model_form
arthanson Oct 23, 2024
7e6bb0e
7699 graphql, tables
arthanson Oct 23, 2024
8a63707
7699 fixes
arthanson Oct 23, 2024
be59775
7699 fixes
arthanson Oct 23, 2024
76e438d
7699 fixes
arthanson Oct 23, 2024
ee99056
7699 fixes
arthanson Oct 24, 2024
071b960
7699 fix tests
arthanson Oct 24, 2024
4112af5
7699 fix graphql tests for clusters reference
arthanson Oct 24, 2024
65295f6
7699 fix dcim tests
arthanson Oct 24, 2024
9108915
7699 fix ipam tests
arthanson Oct 24, 2024
c73902c
7699 fix tests
arthanson Oct 24, 2024
cfdab0e
7699 use mixin for model
arthanson Oct 24, 2024
3525a3a
7699 change mixin name
arthanson Oct 24, 2024
d7b204a
7699 scope form
arthanson Oct 24, 2024
8ca7cdd
7699 scope form
arthanson Oct 24, 2024
277b175
7699 scoped form, fitlerset
arthanson Oct 24, 2024
62358f6
7699 review changes
arthanson Oct 25, 2024
c75bfe1
7699 move ScopedFilterset
arthanson Oct 28, 2024
c500545
7699 move CachedScopeMixin
arthanson Oct 28, 2024
effe920
7699 review changes
arthanson Oct 28, 2024
69af847
Merge branch 'feature' into 7699-cluster-location
arthanson Oct 28, 2024
7fc0e4f
Merge branch 'feature' into 7699-cluster-location
arthanson Oct 30, 2024
0c7710d
7699 review changes
arthanson Oct 30, 2024
5c022f0
7699 refactor mixins
arthanson Oct 30, 2024
88aa554
7699 _sitegroup -> _site_group
arthanson Oct 30, 2024
e1e6bfd
7699 update docstring
arthanson Oct 30, 2024
45f29de
Merge branch 'feature' into 7699-cluster-location
jeremystretch Oct 31, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/models/virtualization/cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@ The cluster's operational status.
!!! tip
Additional statuses may be defined by setting `Cluster.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.

### Site
### Scope

The [site](../dcim/site.md) with which the cluster is associated.
The [region](../dcim/region.md), [site](../dcim/site.md) or [location](../dcim/location.md) with which this cluster is associated.
arthanson marked this conversation as resolved.
Show resolved Hide resolved
5 changes: 4 additions & 1 deletion netbox/dcim/graphql/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -726,9 +726,12 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje
locations: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]]
asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]]
circuit_terminations: List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]
clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]
vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]

@strawberry_django.field
def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]:
arthanson marked this conversation as resolved.
Show resolved Hide resolved
return self._clusters.all()


@strawberry_django.type(
models.SiteGroup,
Expand Down
4 changes: 2 additions & 2 deletions netbox/dcim/models/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -958,10 +958,10 @@ def clean(self):
})

# 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:
arthanson marked this conversation as resolved.
Show resolved Hide resolved
raise ValidationError({
'cluster': _("The assigned cluster belongs to a different site ({site})").format(
site=self.cluster.site
site=self.cluster._site
)
})

Expand Down
9 changes: 5 additions & 4 deletions netbox/dcim/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -601,11 +601,12 @@ def test_device_mismatched_site_cluster(self):
Site.objects.bulk_create(sites)

clusters = (
Cluster(name='Cluster 1', type=cluster_type, site=sites[0]),
Cluster(name='Cluster 2', type=cluster_type, site=sites[1]),
Cluster(name='Cluster 3', type=cluster_type, site=None),
Cluster(name='Cluster 1', type=cluster_type, scope=sites[0]),
Cluster(name='Cluster 2', type=cluster_type, scope=sites[1]),
Cluster(name='Cluster 3', type=cluster_type, scope=None),
)
Cluster.objects.bulk_create(clusters)
for cluster in clusters:
cluster.save()

device_type = DeviceType.objects.first()
device_role = DeviceRole.objects.first()
Expand Down
4 changes: 2 additions & 2 deletions netbox/extras/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ def test_annotation_same_as_get_for_object_virtualmachine_relations(self):
name="Cluster",
group=cluster_group,
type=cluster_type,
site=site,
scope=site,
)

region_context = ConfigContext.objects.create(
Expand Down Expand Up @@ -366,7 +366,7 @@ def test_virtualmachine_site_context(self):
"""
site = Site.objects.first()
cluster_type = ClusterType.objects.create(name="Cluster Type")
cluster = Cluster.objects.create(name="Cluster", type=cluster_type, site=site)
cluster = Cluster.objects.create(name="Cluster", type=cluster_type, scope=site)
vm_role = DeviceRole.objects.first()

# Create a ConfigContext associated with the site
Expand Down
2 changes: 1 addition & 1 deletion netbox/ipam/querysets.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ def get_for_virtualmachine(self, vm):

# Find all relevant VLANGroups
q = Q()
site = vm.site or vm.cluster.site
site = vm.site or vm.cluster._site
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be adapted for the other scope types as well, as VLAN groups can also be assigned to different scopes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

? Not sure I follow this - this is for VLAN, not VLANGroup and any higher scope assignment will filter down to _site - so _site should pick up all the assignments?

if vm.cluster:
# Add VLANGroups scoped to the assigned cluster (or its group)
q |= Q(
Expand Down
9 changes: 5 additions & 4 deletions netbox/ipam/tests/test_filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1674,11 +1674,12 @@ def setUpTestData(cls):

cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
clusters = (
Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0], site=sites[0]),
Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1], site=sites[1]),
Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2], site=sites[2]),
Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0], scope=sites[0]),
Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1], scope=sites[1]),
Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2], scope=sites[2]),
)
Cluster.objects.bulk_create(clusters)
for cluster in clusters:
cluster.save()

virtual_machines = (
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]),
Expand Down
59 changes: 59 additions & 0 deletions netbox/netbox/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange
from dcim.models import Location, Region, Site, SiteGroup
from extras.choices import CustomFieldFilterLogicChoices
from extras.filters import TagFilter
from extras.models import CustomField, SavedFilter
Expand All @@ -24,6 +25,7 @@
'ChangeLoggedModelFilterSet',
'NetBoxModelFilterSet',
'OrganizationalModelFilterSet',
'ScopedFilterSet',
)


Expand Down Expand Up @@ -325,3 +327,60 @@ def search(self, queryset, name, value):
models.Q(slug__icontains=value) |
models.Q(description__icontains=value)
)


class ScopedFilterSet(BaseFilterSet):
arthanson marked this conversation as resolved.
Show resolved Hide resolved
"""
Provides additional filtering functionality for location, site, etc.. for Scoped models.
"""
scope_type = filters.ContentTypeFilter()
region_id = filters.TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
lookup_expr='in',
label=_('Region (ID)'),
)
region = filters.TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='_region',
lookup_expr='in',
to_field_name='slug',
label=_('Region (slug)'),
)
site_group_id = filters.TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='_sitegroup',
lookup_expr='in',
label=_('Site group (ID)'),
)
site_group = filters.TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='_sitegroup',
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 = filters.TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
label=_('Location (ID)'),
)
location = filters.TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='_location',
lookup_expr='in',
to_field_name='slug',
label=_('Location (slug)'),
)
40 changes: 39 additions & 1 deletion netbox/netbox/forms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,25 @@

from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q
from django.utils.translation import gettext_lazy as _

from core.models import ObjectType
from extras.choices import *
from extras.models import CustomField, Tag
from utilities.forms import CSVModelForm
from utilities.forms import CSVModelForm, get_field_value
from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.mixins import CheckLastUpdatedMixin
from utilities.templatetags.builtins.filters import bettertitle
from .mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin

__all__ = (
'NetBoxModelForm',
'NetBoxModelImportForm',
'NetBoxModelBulkEditForm',
'NetBoxModelFilterSetForm',
'ScopedForm',
)


Expand Down Expand Up @@ -186,3 +189,38 @@ def _get_custom_fields(self, content_type):

def _get_form_field(self, customfield):
return customfield.to_form_field(set_initial=False, enforce_required=False, enforce_visibility=False)


class ScopedForm(forms.Form):
arthanson marked this conversation as resolved.
Show resolved Hide resolved

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
64 changes: 64 additions & 0 deletions netbox/netbox/models/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from collections import defaultdict
from functools import cached_property

from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation
from django.core.validators import ValidationError
from django.db import models
Expand All @@ -24,6 +25,7 @@
__all__ = (
'BookmarksMixin',
'ChangeLoggingMixin',
'CachedScopeMixin',
'CloningMixin',
'ContactsMixin',
'CustomFieldsMixin',
Expand Down Expand Up @@ -580,6 +582,68 @@ def sync_data(self):
))


class CachedScopeMixin(models.Model):
arthanson marked this conversation as resolved.
Show resolved Hide resolved
"""
Cached associations for scope to enable efficient filtering - must define scope and scope_type on model
"""
_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
)
_sitegroup = 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._sitegroup = 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._sitegroup = self.scope
elif scope_type == apps.get_model('dcim', 'site'):
self._region = self.scope.region
self._sitegroup = self.scope.group
self._site = self.scope
elif scope_type == apps.get_model('dcim', 'location'):
self._region = self.scope.site.region
self._sitegroup = self.scope.site.group
self._site = self.scope.site
self._location = self.scope
cache_related_objects.alters_data = True


#
# Feature registration
#
Expand Down
31 changes: 27 additions & 4 deletions netbox/virtualization/api/serializers_/clusters.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from dcim.api.serializers_.sites import SiteSerializer
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 tenancy.api.serializers_.tenants import TenantSerializer
from virtualization.choices import *
from virtualization.constants import CLUSTER_SCOPE_TYPES
from virtualization.models import Cluster, ClusterGroup, ClusterType
from utilities.api import get_serializer_for_model

__all__ = (
'ClusterGroupSerializer',
Expand Down Expand Up @@ -45,7 +49,16 @@ class ClusterSerializer(NetBoxModelSerializer):
group = ClusterGroupSerializer(nested=True, required=False, allow_null=True, default=None)
status = ChoiceField(choices=ClusterStatusChoices, required=False)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
site = SiteSerializer(nested=True, required=False, allow_null=True, default=None)
scope_type = ContentTypeField(
queryset=ContentType.objects.filter(
model__in=CLUSTER_SCOPE_TYPES
),
allow_null=True,
required=False,
default=None
)
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
scope = serializers.SerializerMethodField(read_only=True)

# Related object counts
device_count = RelatedObjectCountField('devices')
Expand All @@ -54,8 +67,18 @@ class ClusterSerializer(NetBoxModelSerializer):
class Meta:
model = Cluster
fields = [
'id', 'url', 'display_url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site',
'id', 'url', 'display_url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'scope_type', 'scope_id', 'scope',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'virtualmachine_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'virtualmachine_count')

@extend_schema_field(serializers.JSONField(allow_null=True))
def get_scope(self, obj):
if obj.scope_id is None:
return None
serializer = get_serializer_for_model(obj.scope)
context = {'request': self.context['request']}
return serializer(obj.scope, nested=True, context=context).data


2 changes: 1 addition & 1 deletion netbox/virtualization/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def ready(self):

# Register denormalized fields
denormalized.register(VirtualMachine, 'cluster', {
'site': 'site',
'site': '_site',
})

# Register counters
Expand Down
4 changes: 4 additions & 0 deletions netbox/virtualization/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# models values for ContentTypes which may be CircuitTermination scope types
arthanson marked this conversation as resolved.
Show resolved Hide resolved
CLUSTER_SCOPE_TYPES = (
'region', 'sitegroup', 'site', 'location',
)
Loading