diff --git a/netbox/virtualization/constants.py b/netbox/virtualization/constants.py new file mode 100644 index 000000000..58c93be68 --- /dev/null +++ b/netbox/virtualization/constants.py @@ -0,0 +1,4 @@ +# models values for ContentTypes which may be CircuitTermination scope types +CLUSTER_SCOPE_TYPES = ( + 'region', 'sitegroup', 'site', 'location', +) diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index ec0831f9f..35928f91a 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -38,42 +38,42 @@ class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet) class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): - region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), - field_name='site__region', - lookup_expr='in', - label=_('Region (ID)'), - ) - region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), - field_name='site__region', - lookup_expr='in', - to_field_name='slug', - label=_('Region (slug)'), - ) - site_group_id = TreeNodeMultipleChoiceFilter( - queryset=SiteGroup.objects.all(), - field_name='site__group', - lookup_expr='in', - label=_('Site group (ID)'), - ) - site_group = TreeNodeMultipleChoiceFilter( - queryset=SiteGroup.objects.all(), - field_name='site__group', - lookup_expr='in', - to_field_name='slug', - label=_('Site group (slug)'), - ) - site_id = django_filters.ModelMultipleChoiceFilter( - queryset=Site.objects.all(), - label=_('Site (ID)'), - ) - site = django_filters.ModelMultipleChoiceFilter( - field_name='site__slug', - queryset=Site.objects.all(), - to_field_name='slug', - label=_('Site (slug)'), - ) + # 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( queryset=ClusterGroup.objects.all(), label=_('Parent group (ID)'), diff --git a/netbox/virtualization/migrations/0042_cluster_scope.py b/netbox/virtualization/migrations/0042_cluster_scope.py new file mode 100644 index 000000000..ed8d8bd88 --- /dev/null +++ b/netbox/virtualization/migrations/0042_cluster_scope.py @@ -0,0 +1,51 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def copy_site_assignments(apps, schema_editor): + """ + Copy site ForeignKey values to the scope GFK. + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + Cluster = apps.get_model('virtualization', 'Cluster') + Site = apps.get_model('dcim', 'Site') + + Cluster.objects.filter(site__isnull=False).update( + scope_type=ContentType.objects.get_for_model(Site), + scope_id=models.F('site_id') + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('virtualization', '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 + ), + + ] diff --git a/netbox/virtualization/migrations/0043_clusters_cached_relations.py b/netbox/virtualization/migrations/0043_clusters_cached_relations.py new file mode 100644 index 000000000..eed9ae5bb --- /dev/null +++ b/netbox/virtualization/migrations/0043_clusters_cached_relations.py @@ -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', + ), + + ] diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py index b8921c603..42a7a54cd 100644 --- a/netbox/virtualization/models/clusters.py +++ b/netbox/virtualization/models/clusters.py @@ -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.db import models 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.features import ContactsMixin from virtualization.choices import * +from virtualization.constants import CLUSTER_SCOPE_TYPES __all__ = ( 'Cluster', @@ -76,13 +78,22 @@ class Cluster(ContactsMixin, PrimaryModel): blank=True, null=True ) - site = models.ForeignKey( - to='dcim.Site', + scope_type = models.ForeignKey( + to='contenttypes.ContentType', on_delete=models.PROTECT, - related_name='clusters', + limit_choices_to=models.Q(model__in=CLUSTER_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' + ) # Generic relations vlan_groups = GenericRelation( @@ -92,8 +103,38 @@ class Cluster(ContactsMixin, PrimaryModel): 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 = ( - 'type', 'group', 'status', 'tenant', 'site', + 'scope_type', 'scope_id', 'type', 'group', 'status', 'tenant', ) prerequisite_models = ( '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}" ).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