7699 Add Scope to Cluster

This commit is contained in:
Arthur Hanson 2024-10-23 12:00:20 -07:00
parent ef1fdf0a01
commit 2bb49d7db6
5 changed files with 247 additions and 41 deletions

View File

@ -0,0 +1,4 @@
# models values for ContentTypes which may be CircuitTermination scope types
CLUSTER_SCOPE_TYPES = (
'region', 'sitegroup', 'site', 'location',
)

View File

@ -38,42 +38,42 @@ class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet)
class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
region_id = TreeNodeMultipleChoiceFilter( # region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), # queryset=Region.objects.all(),
field_name='site__region', # field_name='site__region',
lookup_expr='in', # lookup_expr='in',
label=_('Region (ID)'), # label=_('Region (ID)'),
) # )
region = TreeNodeMultipleChoiceFilter( # region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), # queryset=Region.objects.all(),
field_name='site__region', # field_name='site__region',
lookup_expr='in', # lookup_expr='in',
to_field_name='slug', # to_field_name='slug',
label=_('Region (slug)'), # label=_('Region (slug)'),
) # )
site_group_id = TreeNodeMultipleChoiceFilter( # site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(), # queryset=SiteGroup.objects.all(),
field_name='site__group', # field_name='site__group',
lookup_expr='in', # lookup_expr='in',
label=_('Site group (ID)'), # label=_('Site group (ID)'),
) # )
site_group = TreeNodeMultipleChoiceFilter( # site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(), # queryset=SiteGroup.objects.all(),
field_name='site__group', # field_name='site__group',
lookup_expr='in', # lookup_expr='in',
to_field_name='slug', # to_field_name='slug',
label=_('Site group (slug)'), # label=_('Site group (slug)'),
) # )
site_id = django_filters.ModelMultipleChoiceFilter( # site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), # queryset=Site.objects.all(),
label=_('Site (ID)'), # label=_('Site (ID)'),
) # )
site = django_filters.ModelMultipleChoiceFilter( # site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug', # field_name='site__slug',
queryset=Site.objects.all(), # queryset=Site.objects.all(),
to_field_name='slug', # to_field_name='slug',
label=_('Site (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)'),

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', '0041_charfield_null_choices'),
]
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,85 @@
import django.db.models.deletion
from django.db import migrations, models
def populate_denormalized_fields(apps, schema_editor):
"""
Copy site ForeignKey values to the scope GFK.
"""
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._sitegroup_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', '_sitegroup', '_site'])
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0042_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='_clusters',
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='_clusters',
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='_clusters',
to='dcim.site',
),
),
migrations.AddField(
model_name='cluster',
name='_sitegroup',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='_clusters',
to='dcim.sitegroup',
),
),
# Populate denormalized FK values
migrations.RunPython(
code=populate_denormalized_fields,
reverse_code=migrations.RunPython.noop
),
# Delete the site ForeignKey
migrations.RemoveField(
model_name='cluster',
name='site',
),
]

View File

@ -1,4 +1,5 @@
from django.contrib.contenttypes.fields import GenericRelation from django.apps import apps
from django.contrib.contenttypes.fields import GenericForeignKey, 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 _
@ -7,6 +8,7 @@ from dcim.models import Device
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 *
from virtualization.constants import CLUSTER_SCOPE_TYPES
__all__ = ( __all__ = (
'Cluster', 'Cluster',
@ -76,13 +78,22 @@ class Cluster(ContactsMixin, PrimaryModel):
blank=True, blank=True,
null=True null=True
) )
site = models.ForeignKey( scope_type = models.ForeignKey(
to='dcim.Site', to='contenttypes.ContentType',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='clusters', limit_choices_to=models.Q(model__in=CLUSTER_SCOPE_TYPES),
related_name='+',
blank=True, blank=True,
null=True null=True
) )
scope_id = models.PositiveBigIntegerField(
blank=True,
null=True
)
scope = GenericForeignKey(
ct_field='scope_type',
fk_field='scope_id'
)
# Generic relations # Generic relations
vlan_groups = GenericRelation( vlan_groups = GenericRelation(
@ -92,8 +103,38 @@ class Cluster(ContactsMixin, PrimaryModel):
related_query_name='cluster' related_query_name='cluster'
) )
# Cached associations to enable efficient filtering
_location = models.ForeignKey(
to='dcim.Location',
on_delete=models.CASCADE,
related_name='_clusters',
blank=True,
null=True
)
_site = models.ForeignKey(
to='dcim.Site',
on_delete=models.CASCADE,
related_name='_clusters',
blank=True,
null=True
)
_region = models.ForeignKey(
to='dcim.Region',
on_delete=models.CASCADE,
related_name='_clusters',
blank=True,
null=True
)
_sitegroup = models.ForeignKey(
to='dcim.SiteGroup',
on_delete=models.CASCADE,
related_name='_clusters',
blank=True,
null=True
)
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',
@ -131,3 +172,28 @@ class Cluster(ContactsMixin, PrimaryModel):
"{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=self.site)
}) })
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