From 2bb49d7db6311fe8bc46ac6e81ebf54a0866b873 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 23 Oct 2024 12:00:20 -0700 Subject: [PATCH 01/31] 7699 Add Scope to Cluster --- netbox/virtualization/constants.py | 4 + netbox/virtualization/filtersets.py | 72 ++++++++-------- .../migrations/0042_cluster_scope.py | 51 +++++++++++ .../0043_clusters_cached_relations.py | 85 +++++++++++++++++++ netbox/virtualization/models/clusters.py | 76 +++++++++++++++-- 5 files changed, 247 insertions(+), 41 deletions(-) create mode 100644 netbox/virtualization/constants.py create mode 100644 netbox/virtualization/migrations/0042_cluster_scope.py create mode 100644 netbox/virtualization/migrations/0043_clusters_cached_relations.py diff --git a/netbox/virtualization/constants.py b/netbox/virtualization/constants.py new file mode 100644 index 00000000000..58c93be6849 --- /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 ec0831f9fc4..35928f91ab0 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -38,42 +38,42 @@ class Meta: 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 00000000000..ed8d8bd885f --- /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 00000000000..eed9ae5bbd3 --- /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 b8921c603d1..42a7a54cd44 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 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 @@ def clean(self): "{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 From dcb3c7c113670dfc5129d78ba90f3577809e285a Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 23 Oct 2024 12:50:12 -0700 Subject: [PATCH 02/31] 7699 Serializer --- docs/models/virtualization/cluster.md | 4 +-- .../api/serializers_/clusters.py | 30 +++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/models/virtualization/cluster.md b/docs/models/virtualization/cluster.md index 50b5dbd1d6f..11b273732f7 100644 --- a/docs/models/virtualization/cluster.md +++ b/docs/models/virtualization/cluster.md @@ -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. diff --git a/netbox/virtualization/api/serializers_/clusters.py b/netbox/virtualization/api/serializers_/clusters.py index b64b6e7bafe..2c123717013 100644 --- a/netbox/virtualization/api/serializers_/clusters.py +++ b/netbox/virtualization/api/serializers_/clusters.py @@ -1,9 +1,15 @@ +from django.contrib.contenttypes.models import ContentType +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + from dcim.api.serializers_.sites import SiteSerializer -from netbox.api.fields import ChoiceField, RelatedObjectCountField +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', @@ -46,6 +52,16 @@ class ClusterSerializer(NetBoxModelSerializer): 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') @@ -54,8 +70,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 + + From 33b4beba10eab1344aa4758164eadd387151af45 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 23 Oct 2024 12:55:13 -0700 Subject: [PATCH 03/31] 7699 filterset --- netbox/virtualization/filtersets.py | 91 +++++++++++++++++------------ 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 35928f91ab0..d340a623ab1 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -3,13 +3,13 @@ from django.utils.translation import gettext as _ from dcim.filtersets import CommonInterfaceFilterSet -from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Location, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate from ipam.filtersets import PrimaryIPFilterSet from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet -from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter +from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from .choices import * from .models import * @@ -38,42 +38,57 @@ class Meta: 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)'), - # ) + 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='_sitegroup', + lookup_expr='in', + label=_('Site group (ID)'), + ) + site_group = 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 = 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)'), + ) group_id = django_filters.ModelMultipleChoiceFilter( queryset=ClusterGroup.objects.all(), label=_('Parent group (ID)'), From 286f56bfc60330b58ca45365147896a22c94db80 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 23 Oct 2024 13:01:13 -0700 Subject: [PATCH 04/31] 7699 bulk_edit --- netbox/virtualization/forms/bulk_edit.py | 53 ++++++++++++++---------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index 2bd3434ac0f..19b614ac61f 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -1,18 +1,20 @@ from django import forms +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN -from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Site from extras.models import ConfigTemplate from ipam.models import VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant -from utilities.forms import BulkRenameForm, add_blank_choice -from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms import BulkRenameForm, add_blank_choice, get_field_value +from utilities.forms.fields import CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet -from utilities.forms.widgets import BulkEditNullBooleanSelect +from utilities.forms.widgets import BulkEditNullBooleanSelect, HTMXSelect from virtualization.choices import * +from virtualization.constants import CLUSTER_SCOPE_TYPES from virtualization.models import * __all__ = ( @@ -77,24 +79,18 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - region = DynamicModelChoiceField( - label=_('Region'), - queryset=Region.objects.all(), + scope_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(model__in=CLUSTER_SCOPE_TYPES), + widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}), required=False, + label=_('Scope type') ) - site_group = DynamicModelChoiceField( - label=_('Site group'), - queryset=SiteGroup.objects.all(), + scope = DynamicModelChoiceField( + label=_('Scope'), + queryset=Site.objects.none(), # Initial queryset required=False, - ) - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } + disabled=True, + selector=True ) description = forms.CharField( label=_('Description'), @@ -106,13 +102,28 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): model = Cluster fieldsets = ( FieldSet('type', 'group', 'status', 'tenant', 'description'), - FieldSet('region', 'site_group', 'site', name=_('Site')), + FieldSet('scope_type', 'scope', name=_('Scope')), ) nullable_fields = ( - 'group', 'site', 'tenant', 'description', 'comments', + 'group', 'scope', 'tenant', 'description', 'comments', ) + 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 VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): status = forms.ChoiceField( label=_('Status'), From 4c3d1ce95b8ce1bb771d1fdd0e9347250157353c Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 23 Oct 2024 13:04:12 -0700 Subject: [PATCH 05/31] 7699 bulk_import --- netbox/virtualization/forms/bulk_import.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 17efc567ab4..b9126a8c0d5 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -1,3 +1,4 @@ +from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ from dcim.choices import InterfaceModeChoices @@ -6,8 +7,9 @@ from ipam.models import VRF from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField +from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField from virtualization.choices import * +from virtualization.constants import CLUSTER_SCOPE_TYPES from virtualization.models import * __all__ = ( @@ -55,6 +57,11 @@ class ClusterImportForm(NetBoxModelImportForm): choices=ClusterStatusChoices, help_text=_('Operational status') ) + scope_type = CSVContentTypeField( + queryset=ContentType.objects.filter(model__in=CLUSTER_SCOPE_TYPES), + required=False, + label=_('Scope type (app & model)') + ) site = CSVModelChoiceField( label=_('Site'), queryset=Site.objects.all(), @@ -72,7 +79,10 @@ class ClusterImportForm(NetBoxModelImportForm): class Meta: 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): From d19cef49740a32afb22028031dbf721c176e288b Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 23 Oct 2024 13:13:01 -0700 Subject: [PATCH 06/31] 7699 model_form --- netbox/virtualization/forms/filtersets.py | 19 +++++---- netbox/virtualization/forms/model_forms.py | 47 +++++++++++++++++++--- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 7c040d94856..695641e4e4e 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -1,7 +1,7 @@ from django import forms 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.models import ConfigTemplate from ipam.models import VRF @@ -43,7 +43,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi fieldsets = ( FieldSet('q', 'filter_id', 'tag'), 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('contact', 'contact_role', 'contact_group', name=_('Contacts')), ) @@ -58,11 +58,6 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi required=False, label=_('Region') ) - status = forms.MultipleChoiceField( - label=_('Status'), - choices=ClusterStatusChoices, - required=False - ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, @@ -78,6 +73,16 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi }, 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( queryset=ClusterGroup.objects.all(), required=False, diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 9ffc914ab4b..c18d784b4d2 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ @@ -13,8 +14,12 @@ from utilities.forms.fields import ( CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, ) +from utilities.forms import get_field_value +from utilities.forms.fields import ContentTypeChoiceField from utilities.forms.rendering import FieldSet from utilities.forms.widgets import HTMXSelect +from utilities.templatetags.builtins.filters import bettertitle +from virtualization.constants import CLUSTER_SCOPE_TYPES from virtualization.models import * __all__ = ( @@ -67,25 +72,57 @@ class ClusterForm(TenancyForm, NetBoxModelForm): queryset=ClusterGroup.objects.all(), required=False ) - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), + scope_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(model__in=CLUSTER_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 ) comments = CommentField() 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')), ) class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'status', 'tenant', 'site', 'description', 'comments', 'tags', + 'name', 'type', 'group', 'status', 'tenant', 'scope_type', 'description', 'comments', 'tags', ) + 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) + + 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 ClusterAddDevicesForm(forms.Form): region = DynamicModelChoiceField( From 7e6bb0e6bc628d0972378dfbf0f5487a15c5e434 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 23 Oct 2024 13:16:39 -0700 Subject: [PATCH 07/31] 7699 graphql, tables --- netbox/virtualization/graphql/types.py | 15 +++++++++++---- netbox/virtualization/tables/clusters.py | 3 ++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index 2d872322b6e..4af31fc2e1a 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -1,4 +1,4 @@ -from typing import Annotated, List +from typing import Annotated, List, Union import strawberry import strawberry_django @@ -31,18 +31,25 @@ class ComponentType(NetBoxObjectType): @strawberry_django.type( models.Cluster, - fields='__all__', + exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_sitegroup'), filters=ClusterFilter ) class ClusterType(VLANGroupsMixin, NetBoxObjectType): type: Annotated["ClusterTypeType", strawberry.lazy('virtualization.graphql.types')] | None group: Annotated["ClusterGroupType", strawberry.lazy('virtualization.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')]] 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( models.ClusterGroup, diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index d3c799fb99e..28a2b00dc1c 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -75,7 +75,8 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): ) site = tables.Column( verbose_name=_('Site'), - linkify=True + linkify=True, + accessor='_site' ) device_count = columns.LinkedCountColumn( viewname='dcim:device_list', From 8a6370738691025000b4572c2a5636f666574d6e Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 23 Oct 2024 14:17:24 -0700 Subject: [PATCH 08/31] 7699 fixes --- netbox/virtualization/api/serializers_/clusters.py | 3 --- .../migrations/0043_clusters_cached_relations.py | 10 ++++++++++ netbox/virtualization/models/clusters.py | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/netbox/virtualization/api/serializers_/clusters.py b/netbox/virtualization/api/serializers_/clusters.py index 2c123717013..adc31a73ceb 100644 --- a/netbox/virtualization/api/serializers_/clusters.py +++ b/netbox/virtualization/api/serializers_/clusters.py @@ -1,8 +1,6 @@ from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from rest_framework import serializers - -from dcim.api.serializers_.sites import SiteSerializer from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField from netbox.api.serializers import NetBoxModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer @@ -51,7 +49,6 @@ 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 diff --git a/netbox/virtualization/migrations/0043_clusters_cached_relations.py b/netbox/virtualization/migrations/0043_clusters_cached_relations.py index eed9ae5bbd3..cfc7bdb107e 100644 --- a/netbox/virtualization/migrations/0043_clusters_cached_relations.py +++ b/netbox/virtualization/migrations/0043_clusters_cached_relations.py @@ -81,5 +81,15 @@ class Migration(migrations.Migration): model_name='cluster', name='site', ), + migrations.RemoveConstraint( + model_name='cluster', + name='virtualization_cluster_unique_site_name', + ), + migrations.AddConstraint( + model_name='cluster', + constraint=models.UniqueConstraint( + fields=('_site', 'name'), name='virtualization_cluster_unique__site_name' + ), + ), ] diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py index 42a7a54cd44..da4a68df580 100644 --- a/netbox/virtualization/models/clusters.py +++ b/netbox/virtualization/models/clusters.py @@ -148,8 +148,8 @@ class Meta: name='%(app_label)s_%(class)s_unique_group_name' ), models.UniqueConstraint( - fields=('site', 'name'), - name='%(app_label)s_%(class)s_unique_site_name' + fields=('_site', 'name'), + name='%(app_label)s_%(class)s_unique__site_name' ), ) verbose_name = _('cluster') From be59775d568d68d0dc91a573fc468804bae1fa75 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 23 Oct 2024 14:48:17 -0700 Subject: [PATCH 09/31] 7699 fixes --- .../migrations/0043_clusters_cached_relations.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/netbox/virtualization/migrations/0043_clusters_cached_relations.py b/netbox/virtualization/migrations/0043_clusters_cached_relations.py index cfc7bdb107e..35e7844480b 100644 --- a/netbox/virtualization/migrations/0043_clusters_cached_relations.py +++ b/netbox/virtualization/migrations/0043_clusters_cached_relations.py @@ -76,20 +76,19 @@ class Migration(migrations.Migration): 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.RemoveConstraint( - model_name='cluster', - name='virtualization_cluster_unique_site_name', - ), migrations.AddConstraint( model_name='cluster', constraint=models.UniqueConstraint( fields=('_site', 'name'), name='virtualization_cluster_unique__site_name' ), ), - ] From 76e438dea9f0c0e863ea59faf250ae91879dcade Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 23 Oct 2024 14:59:13 -0700 Subject: [PATCH 10/31] 7699 fixes --- .../virtualization/models/virtualmachines.py | 2 +- netbox/virtualization/tests/test_api.py | 3 ++- netbox/virtualization/tests/test_filtersets.py | 18 ++++++++++-------- netbox/virtualization/tests/test_views.py | 13 +++++++------ 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index 0767b2c135c..e7bdc139f1a 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -238,7 +238,7 @@ def save(self, *args, **kwargs): # Assign site from cluster if not set if self.cluster and not self.site: - self.site = self.cluster.site + self.site = self.cluster._site super().save(*args, **kwargs) diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 69728f67c11..a30cf091082 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -112,7 +112,8 @@ def setUpTestData(cls): 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.objects.bulk_create(clusters) + for cluster in clusters: + cluster.save() cls.create_data = [ { diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index d2e6cc05f8e..41cd7ca50b7 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -136,7 +136,7 @@ def setUpTestData(cls): type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED, - site=sites[0], + scope=sites[0], tenant=tenants[0], description='foobar1' ), @@ -145,7 +145,7 @@ def setUpTestData(cls): type=cluster_types[1], group=cluster_groups[1], status=ClusterStatusChoices.STATUS_STAGING, - site=sites[1], + scope=sites[1], tenant=tenants[1], description='foobar2' ), @@ -154,12 +154,13 @@ def setUpTestData(cls): type=cluster_types[2], group=cluster_groups[2], status=ClusterStatusChoices.STATUS_ACTIVE, - site=sites[2], + scope=sites[2], tenant=tenants[2], description='foobar3' ), ) - Cluster.objects.bulk_create(clusters) + for cluster in clusters: + cluster.save() def test_q(self): params = {'q': 'foobar1'} @@ -272,11 +273,12 @@ def setUpTestData(cls): Site.objects.bulk_create(sites) clusters = ( - Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], site=sites[0]), - Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], site=sites[1]), - Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], site=sites[2]), + 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], scope=sites[1]), + 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 = ( Platform(name='Platform 1', slug='platform-1'), diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 3c6a058c989..f8f87035331 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -117,11 +117,12 @@ def setUpTestData(cls): ClusterType.objects.bulk_create(clustertypes) clusters = ( - Cluster(name='Cluster 1', 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, site=sites[0]), - Cluster(name='Cluster 3', 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, scope=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') @@ -131,7 +132,8 @@ def setUpTestData(cls): 'type': clustertypes[1].pk, 'status': ClusterStatusChoices.STATUS_OFFLINE, 'tenant': None, - 'site': sites[1].pk, + 'scope_type': ContentType.objects.get_for_model(Site).pk, + 'scope': sites[1].pk, 'comments': 'Some comments', 'tags': [t.pk for t in tags], } @@ -155,7 +157,6 @@ def setUpTestData(cls): 'type': clustertypes[1].pk, 'status': ClusterStatusChoices.STATUS_OFFLINE, 'tenant': None, - 'site': sites[1].pk, 'comments': 'New comments', } From ee990560c10ae81616b5b348a5769685bb45e106 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 24 Oct 2024 07:27:57 -0700 Subject: [PATCH 11/31] 7699 fixes --- netbox/virtualization/apps.py | 2 +- netbox/virtualization/forms/model_forms.py | 6 ++++++ netbox/virtualization/models/clusters.py | 14 +++++++++++--- netbox/virtualization/models/virtualmachines.py | 2 +- netbox/virtualization/tests/test_api.py | 7 ++++--- netbox/virtualization/tests/test_models.py | 9 +++++---- netbox/virtualization/tests/test_views.py | 10 ++++++---- 7 files changed, 34 insertions(+), 16 deletions(-) diff --git a/netbox/virtualization/apps.py b/netbox/virtualization/apps.py index ebcc591bfb5..65ce0f11252 100644 --- a/netbox/virtualization/apps.py +++ b/netbox/virtualization/apps.py @@ -17,7 +17,7 @@ def ready(self): # Register denormalized fields denormalized.register(VirtualMachine, 'cluster', { - 'site': 'site', + 'site': '_site', }) # Register counters diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index c18d784b4d2..3a1517ca6ee 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -123,6 +123,12 @@ def __init__(self, *args, **kwargs): if self.instance and scope_type_id != self.instance.scope_type_id: self.initial['scope'] = None + def clean(self): + super().clean() + + # Assign the selected scope (if any) + self.instance.scope = self.cleaned_data.get('scope') + class ClusterAddDevicesForm(forms.Form): region = DynamicModelChoiceField( diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py index da4a68df580..16ac650b336 100644 --- a/netbox/virtualization/models/clusters.py +++ b/netbox/virtualization/models/clusters.py @@ -164,13 +164,21 @@ def get_status_color(self): def clean(self): super().clean() + site = 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'): + site = self.scope.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 nonsite_devices := Device.objects.filter(cluster=self).exclude(site=self.site).count(): + if not self._state.adding and site: + if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=site).count(): raise ValidationError({ '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) }) def save(self, *args, **kwargs): diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index e7bdc139f1a..a48feddb90c 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -181,7 +181,7 @@ def clean(self): }) # 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({ 'cluster': _( 'The selected cluster ({cluster}) is not assigned to this site ({site}).' diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index a30cf091082..d11b46b667e 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -157,11 +157,12 @@ def setUpTestData(cls): Site.objects.bulk_create(sites) clusters = ( - Cluster(name='Cluster 1', type=clustertype, site=sites[0], group=clustergroup), - Cluster(name='Cluster 2', type=clustertype, site=sites[1], group=clustergroup), + Cluster(name='Cluster 1', type=clustertype, scope=sites[0], group=clustergroup), + Cluster(name='Cluster 2', type=clustertype, scope=sites[1], group=clustergroup), 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]) device2 = create_test_device('device2', site=sites[1], cluster=clusters[1]) diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py index a4e8d794720..7be423bf175 100644 --- a/netbox/virtualization/tests/test_models.py +++ b/netbox/virtualization/tests/test_models.py @@ -54,11 +54,12 @@ def test_vm_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() # VM with site only should pass VirtualMachine(name='vm1', site=sites[0]).full_clean() diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index f8f87035331..b9cb7b43787 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -1,3 +1,4 @@ +from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse from netaddr import EUI @@ -202,10 +203,11 @@ def setUpTestData(cls): clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clusters = ( - Cluster(name='Cluster 1', type=clustertype, site=sites[0]), - Cluster(name='Cluster 2', type=clustertype, site=sites[1]), + Cluster(name='Cluster 1', type=clustertype, scope=sites[0]), + Cluster(name='Cluster 2', type=clustertype, scope=sites[1]), ) - Cluster.objects.bulk_create(clusters) + for cluster in clusters: + cluster.save() devices = ( create_test_device('device1', site=sites[0], cluster=clusters[0]), @@ -293,7 +295,7 @@ def setUpTestData(cls): site = Site.objects.create(name='Site 1', slug='site-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') - cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site) + cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, scope=site) virtualmachines = ( VirtualMachine(name='Virtual Machine 1', site=site, cluster=cluster, role=role), VirtualMachine(name='Virtual Machine 2', site=site, cluster=cluster, role=role), From 071b9609527e88b187a77ec7710f6c15fa51ca92 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 24 Oct 2024 07:33:31 -0700 Subject: [PATCH 12/31] 7699 fix tests --- netbox/virtualization/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index d340a623ab1..4bf019bb04b 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -116,7 +116,7 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte class Meta: model = Cluster - fields = ('id', 'name', 'description') + fields = ('id', 'name', 'description', 'scope_id') def search(self, queryset, name, value): if not value.strip(): From 4112af534daf107a22e0dfd6dc0b1c80361687a3 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 24 Oct 2024 08:39:12 -0700 Subject: [PATCH 13/31] 7699 fix graphql tests for clusters reference --- netbox/dcim/graphql/types.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index cd863837a1c..bbfadbfd03a 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -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')]]: + return self._clusters.all() + @strawberry_django.type( models.SiteGroup, From 65295f6d79fac1518168531ce86124bc73bb37c1 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 24 Oct 2024 08:58:07 -0700 Subject: [PATCH 14/31] 7699 fix dcim tests --- netbox/dcim/models/devices.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index b9ba2bb6478..3efb97ce74c 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -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: raise ValidationError({ 'cluster': _("The assigned cluster belongs to a different site ({site})").format( - site=self.cluster.site + site=self.cluster._site ) }) From 91089156049d92b9e0fa2baa343ca769a4b83949 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 24 Oct 2024 09:23:18 -0700 Subject: [PATCH 15/31] 7699 fix ipam tests --- netbox/ipam/querysets.py | 2 +- netbox/ipam/tests/test_filtersets.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 771e9b3b9d4..77ab8194a0d 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -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 if vm.cluster: # Add VLANGroups scoped to the assigned cluster (or its group) q |= Q( diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 7bc372fbfa1..872a4e98aeb 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -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]), From c73902c0884e9765c2c9021c28e410b1f7156d9a Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 24 Oct 2024 09:38:04 -0700 Subject: [PATCH 16/31] 7699 fix tests --- netbox/dcim/tests/test_models.py | 9 +++++---- netbox/extras/tests/test_models.py | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index c11badbdd43..e13f5d5ef3a 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -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() diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 188a06a3f29..c90390dd179 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -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( @@ -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 From cfdab0e87f9354c1a59cb366cf6640f4bb6dbf1a Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 24 Oct 2024 11:19:47 -0700 Subject: [PATCH 17/31] 7699 use mixin for model --- netbox/netbox/models/features.py | 64 +++++++++++++++++++ .../0043_clusters_cached_relations.py | 8 +-- netbox/virtualization/models/clusters.py | 59 +---------------- 3 files changed, 70 insertions(+), 61 deletions(-) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index a972277705c..c6685cba03f 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -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 @@ -23,6 +24,7 @@ __all__ = ( 'BookmarksMixin', + 'CachedLocationScopeMixin', 'ChangeLoggingMixin', 'CloningMixin', 'ContactsMixin', @@ -580,6 +582,68 @@ def sync_data(self): )) +class CachedLocationScopeMixin(models.Model): + """ + 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 # diff --git a/netbox/virtualization/migrations/0043_clusters_cached_relations.py b/netbox/virtualization/migrations/0043_clusters_cached_relations.py index 35e7844480b..5b0407b3d57 100644 --- a/netbox/virtualization/migrations/0043_clusters_cached_relations.py +++ b/netbox/virtualization/migrations/0043_clusters_cached_relations.py @@ -32,7 +32,7 @@ class Migration(migrations.Migration): blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, - related_name='_clusters', + related_name='_%(class)ss', to='dcim.location', ), ), @@ -43,7 +43,7 @@ class Migration(migrations.Migration): blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, - related_name='_clusters', + related_name='_%(class)ss', to='dcim.region', ), ), @@ -54,7 +54,7 @@ class Migration(migrations.Migration): blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, - related_name='_clusters', + related_name='_%(class)ss', to='dcim.site', ), ), @@ -65,7 +65,7 @@ class Migration(migrations.Migration): blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, - related_name='_clusters', + related_name='_%(class)ss', to='dcim.sitegroup', ), ), diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py index 16ac650b336..bd83bf88dff 100644 --- a/netbox/virtualization/models/clusters.py +++ b/netbox/virtualization/models/clusters.py @@ -6,7 +6,7 @@ from dcim.models import Device from netbox.models import OrganizationalModel, PrimaryModel -from netbox.models.features import ContactsMixin +from netbox.models.features import CachedLocationScopeMixin, ContactsMixin from virtualization.choices import * from virtualization.constants import CLUSTER_SCOPE_TYPES @@ -44,7 +44,7 @@ class Meta: verbose_name_plural = _('cluster groups') -class Cluster(ContactsMixin, PrimaryModel): +class Cluster(ContactsMixin, CachedLocationScopeMixin, PrimaryModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. """ @@ -103,36 +103,6 @@ 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 = ( 'scope_type', 'scope_id', 'type', 'group', 'status', 'tenant', ) @@ -180,28 +150,3 @@ def clean(self): "{count} devices are assigned as hosts for this cluster but are not in site {site}" ).format(count=nonsite_devices, site=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 From 3525a3a02e194114a0e8dce2ed93508c2d775714 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 24 Oct 2024 11:32:24 -0700 Subject: [PATCH 18/31] 7699 change mixin name --- netbox/netbox/models/features.py | 4 ++-- netbox/virtualization/models/clusters.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index c6685cba03f..e943afb4038 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -24,8 +24,8 @@ __all__ = ( 'BookmarksMixin', - 'CachedLocationScopeMixin', 'ChangeLoggingMixin', + 'CachedScopeMixin', 'CloningMixin', 'ContactsMixin', 'CustomFieldsMixin', @@ -582,7 +582,7 @@ def sync_data(self): )) -class CachedLocationScopeMixin(models.Model): +class CachedScopeMixin(models.Model): """ Cached associations for scope to enable efficient filtering - must define scope and scope_type on model """ diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py index bd83bf88dff..03aef1215e6 100644 --- a/netbox/virtualization/models/clusters.py +++ b/netbox/virtualization/models/clusters.py @@ -6,7 +6,7 @@ from dcim.models import Device from netbox.models import OrganizationalModel, PrimaryModel -from netbox.models.features import CachedLocationScopeMixin, ContactsMixin +from netbox.models.features import CachedScopeMixin, ContactsMixin from virtualization.choices import * from virtualization.constants import CLUSTER_SCOPE_TYPES @@ -44,7 +44,7 @@ class Meta: verbose_name_plural = _('cluster groups') -class Cluster(ContactsMixin, CachedLocationScopeMixin, PrimaryModel): +class Cluster(ContactsMixin, CachedScopeMixin, PrimaryModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. """ From d7b204a83f5de3267e8d4faf65eed8521047ceb9 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 24 Oct 2024 11:42:25 -0700 Subject: [PATCH 19/31] 7699 scope form --- netbox/netbox/forms/base.py | 23 +++++++++++++++++++++- netbox/virtualization/forms/model_forms.py | 22 +++------------------ 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 74ac4b0e005..59fc3abc5f2 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -2,15 +2,17 @@ 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__ = ( @@ -18,6 +20,7 @@ 'NetBoxModelImportForm', 'NetBoxModelBulkEditForm', 'NetBoxModelFilterSetForm', + 'ScopeForm', ) @@ -186,3 +189,21 @@ 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 ScopeForm(forms.Form): + + def _set_scoped_values(): + 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 diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 3a1517ca6ee..5337bd5f53f 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -1,6 +1,5 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ @@ -8,17 +7,15 @@ from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup from extras.models import ConfigTemplate from ipam.models import IPAddress, VLAN, VLANGroup, VRF -from netbox.forms import NetBoxModelForm +from netbox.forms import NetBoxModelForm, ScopeForm from tenancy.forms import TenancyForm from utilities.forms import ConfirmationForm from utilities.forms.fields import ( CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, ) -from utilities.forms import get_field_value from utilities.forms.fields import ContentTypeChoiceField from utilities.forms.rendering import FieldSet from utilities.forms.widgets import HTMXSelect -from utilities.templatetags.builtins.filters import bettertitle from virtualization.constants import CLUSTER_SCOPE_TYPES from virtualization.models import * @@ -62,7 +59,7 @@ class Meta: ) -class ClusterForm(TenancyForm, NetBoxModelForm): +class ClusterForm(TenancyForm, ScopeForm, NetBoxModelForm): type = DynamicModelChoiceField( label=_('Type'), queryset=ClusterType.objects.all() @@ -108,20 +105,7 @@ def __init__(self, *args, **kwargs): kwargs['initial'] = initial 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 - - if self.instance and scope_type_id != self.instance.scope_type_id: - self.initial['scope'] = None + self._set_scoped_values() def clean(self): super().clean() From 8ca7cdd0adb6e5ab79b6e1376d1b333a4990b7a0 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 24 Oct 2024 12:48:04 -0700 Subject: [PATCH 20/31] 7699 scope form --- netbox/netbox/filtersets.py | 58 ++++++++++++++++++++++ netbox/netbox/forms/base.py | 19 ++++++- netbox/virtualization/forms/model_forms.py | 17 ------- 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 637a40bf114..934326fb6f3 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -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 @@ -325,3 +326,60 @@ def search(self, queryset, name, value): models.Q(slug__icontains=value) | models.Q(description__icontains=value) ) + + +class ScopeModelFilterSet(BaseFilterSet): + """ + 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)'), + ) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 59fc3abc5f2..722c95fc33f 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -193,7 +193,24 @@ def _get_form_field(self, customfield): class ScopeForm(forms.Form): - def _set_scoped_values(): + 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) diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 5337bd5f53f..b05dd27da1a 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -96,23 +96,6 @@ class Meta: 'name', 'type', 'group', 'status', 'tenant', 'scope_type', 'description', 'comments', 'tags', ) - 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') - class ClusterAddDevicesForm(forms.Form): region = DynamicModelChoiceField( From 277b175f9f912ea3a49c50c03484b7b22d1d809b Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Thu, 24 Oct 2024 12:53:52 -0700 Subject: [PATCH 21/31] 7699 scoped form, fitlerset --- netbox/netbox/filtersets.py | 3 +- netbox/netbox/forms/base.py | 4 +- netbox/virtualization/filtersets.py | 59 ++-------------------- netbox/virtualization/forms/model_forms.py | 4 +- 4 files changed, 10 insertions(+), 60 deletions(-) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 934326fb6f3..5648e914a79 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -25,6 +25,7 @@ 'ChangeLoggedModelFilterSet', 'NetBoxModelFilterSet', 'OrganizationalModelFilterSet', + 'ScopedFilterSet', ) @@ -328,7 +329,7 @@ def search(self, queryset, name, value): ) -class ScopeModelFilterSet(BaseFilterSet): +class ScopedFilterSet(BaseFilterSet): """ Provides additional filtering functionality for location, site, etc.. for Scoped models. """ diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 722c95fc33f..9a169592acf 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -20,7 +20,7 @@ 'NetBoxModelImportForm', 'NetBoxModelBulkEditForm', 'NetBoxModelFilterSetForm', - 'ScopeForm', + 'ScopedForm', ) @@ -191,7 +191,7 @@ def _get_form_field(self, customfield): return customfield.to_form_field(set_initial=False, enforce_required=False, enforce_visibility=False) -class ScopeForm(forms.Form): +class ScopedForm(forms.Form): def __init__(self, *args, **kwargs): instance = kwargs.get('instance') diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 4bf019bb04b..5f03629946e 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -3,13 +3,13 @@ from django.utils.translation import gettext as _ from dcim.filtersets import CommonInterfaceFilterSet -from dcim.models import Device, DeviceRole, Platform, Location, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate from ipam.filtersets import PrimaryIPFilterSet -from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet +from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet, ScopedFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet -from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter +from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from .choices import * from .models import * @@ -37,58 +37,7 @@ class Meta: fields = ('id', 'name', 'slug', 'description') -class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): - 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='_sitegroup', - lookup_expr='in', - label=_('Site group (ID)'), - ) - site_group = 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 = 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)'), - ) +class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ScopedFilterSet, ContactModelFilterSet): group_id = django_filters.ModelMultipleChoiceFilter( queryset=ClusterGroup.objects.all(), label=_('Parent group (ID)'), diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index b05dd27da1a..4550f0d61a3 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -7,7 +7,7 @@ from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup from extras.models import ConfigTemplate from ipam.models import IPAddress, VLAN, VLANGroup, VRF -from netbox.forms import NetBoxModelForm, ScopeForm +from netbox.forms import NetBoxModelForm, ScopedForm from tenancy.forms import TenancyForm from utilities.forms import ConfirmationForm from utilities.forms.fields import ( @@ -59,7 +59,7 @@ class Meta: ) -class ClusterForm(TenancyForm, ScopeForm, NetBoxModelForm): +class ClusterForm(TenancyForm, ScopedForm, NetBoxModelForm): type = DynamicModelChoiceField( label=_('Type'), queryset=ClusterType.objects.all() From 62358f6ead6883ddba9fe90dfda049e972c8b9b6 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 25 Oct 2024 15:41:46 -0700 Subject: [PATCH 22/31] 7699 review changes --- docs/models/virtualization/cluster.md | 2 +- netbox/dcim/graphql/types.py | 12 ++++++++++++ netbox/virtualization/constants.py | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/models/virtualization/cluster.md b/docs/models/virtualization/cluster.md index 11b273732f7..9acdb2bc437 100644 --- a/docs/models/virtualization/cluster.md +++ b/docs/models/virtualization/cluster.md @@ -25,4 +25,4 @@ The cluster's operational status. ### Scope -The [region](../dcim/region.md), [site](../dcim/site.md) or [location](../dcim/location.md) with which this 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. diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index bbfadbfd03a..507bc0dd0fb 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -461,6 +461,10 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi devices: List[Annotated["DeviceType", 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.type( models.Manufacturer, @@ -704,6 +708,10 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None: return self.parent + @strawberry_django.field + def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: + return self._clusters.all() + @strawberry_django.type( models.Site, @@ -748,6 +756,10 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None: return self.parent + @strawberry_django.field + def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: + return self._clusters.all() + @strawberry_django.type( models.VirtualChassis, diff --git a/netbox/virtualization/constants.py b/netbox/virtualization/constants.py index 58c93be6849..6154b825e27 100644 --- a/netbox/virtualization/constants.py +++ b/netbox/virtualization/constants.py @@ -1,4 +1,4 @@ -# models values for ContentTypes which may be CircuitTermination scope types +# models values for ContentTypes which may be Cluster scope types CLUSTER_SCOPE_TYPES = ( 'region', 'sitegroup', 'site', 'location', ) From c75bfe147d870010b86ac4827a6f052d72803872 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Mon, 28 Oct 2024 07:55:43 -0700 Subject: [PATCH 23/31] 7699 move ScopedFilterset --- netbox/dcim/filtersets.py | 58 ++++++++++++++++++++++++++++ netbox/netbox/filtersets.py | 59 ----------------------------- netbox/virtualization/filtersets.py | 4 +- 3 files changed, 60 insertions(+), 61 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 04ac3a3d201..4c7ee80edda 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -73,6 +73,7 @@ 'RearPortFilterSet', 'RearPortTemplateFilterSet', 'RegionFilterSet', + 'ScopedFilterSet', 'SiteFilterSet', 'SiteGroupFilterSet', 'VirtualChassisFilterSet', @@ -2331,3 +2332,60 @@ class InterfaceConnectionFilterSet(ConnectionFilterSet): class Meta: model = Interface 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='_sitegroup', + lookup_expr='in', + label=_('Site group (ID)'), + ) + site_group = 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 = 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)'), + ) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 5648e914a79..637a40bf114 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -9,7 +9,6 @@ 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 @@ -25,7 +24,6 @@ 'ChangeLoggedModelFilterSet', 'NetBoxModelFilterSet', 'OrganizationalModelFilterSet', - 'ScopedFilterSet', ) @@ -327,60 +325,3 @@ def search(self, queryset, name, value): models.Q(slug__icontains=value) | models.Q(description__icontains=value) ) - - -class ScopedFilterSet(BaseFilterSet): - """ - 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)'), - ) diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 5f03629946e..ac72bea1233 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -2,12 +2,12 @@ from django.db.models import Q 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 extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate from ipam.filtersets import PrimaryIPFilterSet -from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet, ScopedFilterSet +from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from .choices import * From c5005455f8eb4c5ee70ccd77c16619ed282e1fa0 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Mon, 28 Oct 2024 08:45:03 -0700 Subject: [PATCH 24/31] 7699 move CachedScopeMixin --- netbox/dcim/models/mixins.py | 64 ++++++++++++++++++++++++ netbox/netbox/models/features.py | 64 ------------------------ netbox/virtualization/models/clusters.py | 3 +- 3 files changed, 66 insertions(+), 65 deletions(-) diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index c9be451a05a..ba9050e1065 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -1,6 +1,8 @@ +from django.apps import apps from django.db import models __all__ = ( + 'CachedScopeMixin', 'RenderConfigMixin', ) @@ -27,3 +29,65 @@ def get_config_template(self): return self.role.config_template if self.platform and self.platform.config_template: return self.platform.config_template + + +class CachedScopeMixin(models.Model): + """ + 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 diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index e943afb4038..a972277705c 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -2,7 +2,6 @@ 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 @@ -25,7 +24,6 @@ __all__ = ( 'BookmarksMixin', 'ChangeLoggingMixin', - 'CachedScopeMixin', 'CloningMixin', 'ContactsMixin', 'CustomFieldsMixin', @@ -582,68 +580,6 @@ def sync_data(self): )) -class CachedScopeMixin(models.Model): - """ - 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 # diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py index 03aef1215e6..f1ab2b3da66 100644 --- a/netbox/virtualization/models/clusters.py +++ b/netbox/virtualization/models/clusters.py @@ -5,8 +5,9 @@ from django.utils.translation import gettext_lazy as _ from dcim.models import Device +from dcim.models.mixins import CachedScopeMixin from netbox.models import OrganizationalModel, PrimaryModel -from netbox.models.features import CachedScopeMixin, ContactsMixin +from netbox.models.features import ContactsMixin from virtualization.choices import * from virtualization.constants import CLUSTER_SCOPE_TYPES From effe9204af4d5bb07dff3d2e9c36ce09470a9c80 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Mon, 28 Oct 2024 09:08:17 -0700 Subject: [PATCH 25/31] 7699 review changes --- netbox/dcim/models/devices.py | 7 ++++++ netbox/virtualization/models/clusters.py | 27 ++++++++++++++++-------- netbox/virtualization/tables/clusters.py | 14 ++++++------ 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 3efb97ce74c..47f4ee6c9e5 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -965,6 +965,13 @@ def clean(self): ) }) + 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 + ) + }) + # Validate virtual chassis assignment if self.virtual_chassis and self.vc_position is None: raise ValidationError({ diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py index f1ab2b3da66..ad2aaedc791 100644 --- a/netbox/virtualization/models/clusters.py +++ b/netbox/virtualization/models/clusters.py @@ -135,19 +135,28 @@ def get_status_color(self): def clean(self): super().clean() - site = None + 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'): - site = self.scope.site + location = self.scope + site = location.site # If the Cluster is assigned to a Site, verify that all host Devices belong to that Site. - if not self._state.adding and site: - if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=site).count(): - raise ValidationError({ - 'site': _( - "{count} devices are assigned as hosts for this cluster but are not in site {site}" - ).format(count=nonsite_devices, site=site) - }) + if not self._state.adding: + if site: + if nonsite_devices := Device.objects.filter(cluster=self).exclude(site=site).count(): + raise ValidationError({ + 'scope': _( + "{count} devices are assigned as hosts for this cluster but are not in site {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) + }) diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index 28a2b00dc1c..91807e35b91 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -73,10 +73,12 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): status = columns.ChoiceFieldColumn( verbose_name=_('Status'), ) - site = tables.Column( - verbose_name=_('Site'), - linkify=True, - accessor='_site' + scope_type = columns.ContentTypeColumn( + verbose_name=_('Scope Type'), + ) + scope = tables.Column( + verbose_name=_('Scope'), + linkify=True ) device_count = columns.LinkedCountColumn( viewname='dcim:device_list', @@ -98,7 +100,7 @@ class ClusterTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Cluster fields = ( - 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'site', 'description', 'comments', - 'device_count', 'vm_count', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'scope', 'scope_type', 'description', + 'comments', 'device_count', 'vm_count', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'type', 'group', 'status', 'tenant', 'site', 'device_count', 'vm_count') From 0c7710ddb6c598c556822697064828a57d0aebab Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 30 Oct 2024 11:29:32 -0700 Subject: [PATCH 26/31] 7699 review changes --- netbox/dcim/forms/mixins.py | 45 ++++++++++++++++++++++ netbox/netbox/forms/base.py | 40 +------------------ netbox/virtualization/forms/bulk_import.py | 2 +- netbox/virtualization/forms/model_forms.py | 3 +- 4 files changed, 49 insertions(+), 41 deletions(-) create mode 100644 netbox/dcim/forms/mixins.py diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py new file mode 100644 index 00000000000..57aeeedebc8 --- /dev/null +++ b/netbox/dcim/forms/mixins.py @@ -0,0 +1,45 @@ +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 utilities.forms import get_field_value +from utilities.templatetags.builtins.filters import bettertitle + +__all__ = ( + 'ScopedForm', +) + + +class ScopedForm(forms.Form): + + 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 diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 9a169592acf..74ac4b0e005 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -2,17 +2,15 @@ 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, get_field_value +from utilities.forms import CSVModelForm 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__ = ( @@ -20,7 +18,6 @@ 'NetBoxModelImportForm', 'NetBoxModelBulkEditForm', 'NetBoxModelFilterSetForm', - 'ScopedForm', ) @@ -189,38 +186,3 @@ 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): - - 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 diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index b9126a8c0d5..f529168088c 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -81,7 +81,7 @@ class Meta: model = Cluster fields = ('name', 'type', 'group', 'status', 'scope_type', 'scope_id', 'tenant', 'description', 'comments', 'tags') labels = { - 'scope_id': 'Scope ID', + 'scope_id': _('Scope ID'), } diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 4550f0d61a3..937520787be 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -4,10 +4,11 @@ from django.utils.translation import gettext_lazy as _ from dcim.forms.common import InterfaceCommonForm +from dcim.forms.mixins import ScopedForm from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup from extras.models import ConfigTemplate from ipam.models import IPAddress, VLAN, VLANGroup, VRF -from netbox.forms import NetBoxModelForm, ScopedForm +from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ConfirmationForm from utilities.forms.fields import ( From 5c022f0ae2e977886b3ec486891265eb69bcf947 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 30 Oct 2024 11:53:51 -0700 Subject: [PATCH 27/31] 7699 refactor mixins --- netbox/dcim/constants.py | 5 ++ netbox/dcim/forms/mixins.py | 58 +++++++++++++++++++ netbox/dcim/models/mixins.py | 21 ++++++- .../api/serializers_/clusters.py | 4 +- netbox/virtualization/constants.py | 4 -- netbox/virtualization/forms/bulk_edit.py | 39 ++----------- netbox/virtualization/forms/bulk_import.py | 12 +--- netbox/virtualization/forms/model_forms.py | 15 ----- netbox/virtualization/models/clusters.py | 19 +----- 9 files changed, 94 insertions(+), 83 deletions(-) delete mode 100644 netbox/virtualization/constants.py diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index ba3e6464b2c..df7c18b3283 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -123,3 +123,8 @@ 'powerport': ['poweroutlet', 'powerfeed'], 'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], } + +# models values for ContentTypes which may be Cluster scope types +LOCATION_SCOPE_TYPES = ( + 'region', 'sitegroup', 'site', 'location', +) diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py index 57aeeedebc8..f15bc12d494 100644 --- a/netbox/dcim/forms/mixins.py +++ b/netbox/dcim/forms/mixins.py @@ -2,15 +2,36 @@ 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', ) 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') @@ -43,3 +64,40 @@ def _set_scoped_values(self): 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)') + ) diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index ba9050e1065..9575a9e967f 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -1,5 +1,7 @@ from django.apps import apps +from django.contrib.contenttypes.fields import GenericForeignKey from django.db import models +from dcim.constants import LOCATION_SCOPE_TYPES __all__ = ( 'CachedScopeMixin', @@ -33,8 +35,25 @@ def get_config_template(self): class CachedScopeMixin(models.Model): """ - Cached associations for scope to enable efficient filtering - must define scope and scope_type on model + Cached associations for scope to enable efficient filtering """ + 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, diff --git a/netbox/virtualization/api/serializers_/clusters.py b/netbox/virtualization/api/serializers_/clusters.py index adc31a73ceb..101a5b5a314 100644 --- a/netbox/virtualization/api/serializers_/clusters.py +++ b/netbox/virtualization/api/serializers_/clusters.py @@ -1,3 +1,4 @@ +from dcim.constants import LOCATION_SCOPE_TYPES from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from rest_framework import serializers @@ -5,7 +6,6 @@ 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 @@ -51,7 +51,7 @@ class ClusterSerializer(NetBoxModelSerializer): tenant = TenantSerializer(nested=True, required=False, allow_null=True) scope_type = ContentTypeField( queryset=ContentType.objects.filter( - model__in=CLUSTER_SCOPE_TYPES + model__in=LOCATION_SCOPE_TYPES ), allow_null=True, required=False, diff --git a/netbox/virtualization/constants.py b/netbox/virtualization/constants.py deleted file mode 100644 index 6154b825e27..00000000000 --- a/netbox/virtualization/constants.py +++ /dev/null @@ -1,4 +0,0 @@ -# models values for ContentTypes which may be Cluster scope types -CLUSTER_SCOPE_TYPES = ( - 'region', 'sitegroup', 'site', 'location', -) diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index 19b614ac61f..aaeb259b932 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -1,20 +1,19 @@ from django import forms -from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN +from dcim.forms.mixins import ScopedBulkEditForm from dcim.models import Device, DeviceRole, Platform, Site from extras.models import ConfigTemplate from ipam.models import VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant -from utilities.forms import BulkRenameForm, add_blank_choice, get_field_value -from utilities.forms.fields import CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms import BulkRenameForm, add_blank_choice +from utilities.forms.fields import CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet -from utilities.forms.widgets import BulkEditNullBooleanSelect, HTMXSelect +from utilities.forms.widgets import BulkEditNullBooleanSelect from virtualization.choices import * -from virtualization.constants import CLUSTER_SCOPE_TYPES from virtualization.models import * __all__ = ( @@ -57,7 +56,7 @@ class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('description',) -class ClusterBulkEditForm(NetBoxModelBulkEditForm): +class ClusterBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): type = DynamicModelChoiceField( label=_('Type'), queryset=ClusterType.objects.all(), @@ -79,19 +78,6 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - scope_type = ContentTypeChoiceField( - queryset=ContentType.objects.filter(model__in=CLUSTER_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 - ) description = forms.CharField( label=_('Description'), max_length=200, @@ -109,21 +95,6 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): ) - 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 VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): status = forms.ChoiceField( label=_('Status'), diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index f529168088c..9ccdd68f7b9 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -1,15 +1,14 @@ -from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ from dcim.choices import InterfaceModeChoices +from dcim.forms.mixins import ScopedImportForm from dcim.models import Device, DeviceRole, Platform, Site from extras.models import ConfigTemplate from ipam.models import VRF from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField +from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField from virtualization.choices import * -from virtualization.constants import CLUSTER_SCOPE_TYPES from virtualization.models import * __all__ = ( @@ -38,7 +37,7 @@ class Meta: fields = ('name', 'slug', 'description', 'tags') -class ClusterImportForm(NetBoxModelImportForm): +class ClusterImportForm(ScopedImportForm, NetBoxModelImportForm): type = CSVModelChoiceField( label=_('Type'), queryset=ClusterType.objects.all(), @@ -57,11 +56,6 @@ class ClusterImportForm(NetBoxModelImportForm): choices=ClusterStatusChoices, help_text=_('Operational status') ) - scope_type = CSVContentTypeField( - queryset=ContentType.objects.filter(model__in=CLUSTER_SCOPE_TYPES), - required=False, - label=_('Scope type (app & model)') - ) site = CSVModelChoiceField( label=_('Site'), queryset=Site.objects.all(), diff --git a/netbox/virtualization/forms/model_forms.py b/netbox/virtualization/forms/model_forms.py index 937520787be..28563a82172 100644 --- a/netbox/virtualization/forms/model_forms.py +++ b/netbox/virtualization/forms/model_forms.py @@ -14,10 +14,8 @@ from utilities.forms.fields import ( CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, ) -from utilities.forms.fields import ContentTypeChoiceField from utilities.forms.rendering import FieldSet from utilities.forms.widgets import HTMXSelect -from virtualization.constants import CLUSTER_SCOPE_TYPES from virtualization.models import * __all__ = ( @@ -70,19 +68,6 @@ class ClusterForm(TenancyForm, ScopedForm, NetBoxModelForm): queryset=ClusterGroup.objects.all(), required=False ) - scope_type = ContentTypeChoiceField( - queryset=ContentType.objects.filter(model__in=CLUSTER_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 - ) comments = CommentField() fieldsets = ( diff --git a/netbox/virtualization/models/clusters.py b/netbox/virtualization/models/clusters.py index ad2aaedc791..601ee7f23ec 100644 --- a/netbox/virtualization/models/clusters.py +++ b/netbox/virtualization/models/clusters.py @@ -1,5 +1,5 @@ from django.apps import apps -from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ @@ -9,7 +9,6 @@ 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', @@ -79,22 +78,6 @@ class Cluster(ContactsMixin, CachedScopeMixin, PrimaryModel): blank=True, null=True ) - scope_type = models.ForeignKey( - to='contenttypes.ContentType', - on_delete=models.PROTECT, - 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( From 88aa554477252e00bfe7c7eefff2625da524266e Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 30 Oct 2024 12:07:13 -0700 Subject: [PATCH 28/31] 7699 _sitegroup -> _site_group --- netbox/dcim/filtersets.py | 4 ++-- netbox/dcim/models/mixins.py | 10 +++++----- netbox/virtualization/graphql/types.py | 2 +- .../migrations/0043_clusters_cached_relations.py | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 4c7ee80edda..7802435530d 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -2354,13 +2354,13 @@ class ScopedFilterSet(BaseFilterSet): ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='_sitegroup', + field_name='_site_group', lookup_expr='in', label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='_sitegroup', + field_name='_site_group', lookup_expr='in', to_field_name='slug', label=_('Site group (slug)'), diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index 9575a9e967f..353bcfe4b00 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -75,7 +75,7 @@ class CachedScopeMixin(models.Model): blank=True, null=True ) - _sitegroup = models.ForeignKey( + _site_group = models.ForeignKey( to='dcim.SiteGroup', on_delete=models.CASCADE, related_name='_%(class)ss', @@ -93,20 +93,20 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) def cache_related_objects(self): - self._region = self._sitegroup = self._site = self._location = None + 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._sitegroup = self.scope + self._site_group = self.scope elif scope_type == apps.get_model('dcim', 'site'): self._region = self.scope.region - self._sitegroup = self.scope.group + 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._sitegroup = self.scope.site.group + self._site_group = self.scope.site.group self._site = self.scope.site self._location = self.scope cache_related_objects.alters_data = True diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index 4af31fc2e1a..2a22d168030 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -31,7 +31,7 @@ class ComponentType(NetBoxObjectType): @strawberry_django.type( models.Cluster, - exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_sitegroup'), + exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'), filters=ClusterFilter ) class ClusterType(VLANGroupsMixin, NetBoxObjectType): diff --git a/netbox/virtualization/migrations/0043_clusters_cached_relations.py b/netbox/virtualization/migrations/0043_clusters_cached_relations.py index 5b0407b3d57..bf72fb89a7e 100644 --- a/netbox/virtualization/migrations/0043_clusters_cached_relations.py +++ b/netbox/virtualization/migrations/0043_clusters_cached_relations.py @@ -4,18 +4,18 @@ def populate_denormalized_fields(apps, schema_editor): """ - Copy site ForeignKey values to the scope GFK. + 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._sitegroup_id = cluster.site.group_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', '_sitegroup', '_site']) + Cluster.objects.bulk_update(clusters, ['_region', '_site_group', '_site']) class Migration(migrations.Migration): @@ -60,7 +60,7 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name='cluster', - name='_sitegroup', + name='_site_group', field=models.ForeignKey( blank=True, null=True, From e1e6bfd7579daad535afe2c69721a5d43487fe52 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Wed, 30 Oct 2024 12:14:06 -0700 Subject: [PATCH 29/31] 7699 update docstring --- netbox/dcim/models/mixins.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index 353bcfe4b00..8ea695de253 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -35,7 +35,10 @@ def get_config_template(self): class CachedScopeMixin(models.Model): """ - Cached associations for scope to enable efficient filtering + Mixin for adding a GFK Scope to a model that can point to a Region, SiteGroup, Site, or Location. Includes + cached fields for each to allow efficient filtering. Must do any appropriate validation in the clean method + as this does not have any as validation is generally model specific. + """ scope_type = models.ForeignKey( to='contenttypes.ContentType', From a0c069d880bc40dcde314bc90a2276d8b946727b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 1 Nov 2024 13:53:50 -0400 Subject: [PATCH 30/31] Misc cleanup --- netbox/dcim/constants.py | 2 +- netbox/dcim/forms/mixins.py | 2 ++ netbox/dcim/models/mixins.py | 7 +++---- netbox/templates/virtualization/cluster.html | 8 ++++++-- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index df7c18b3283..4927b01989e 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -124,7 +124,7 @@ 'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], } -# models values for ContentTypes which may be Cluster scope types +# Models which can serve to scope an object by location LOCATION_SCOPE_TYPES = ( 'region', 'sitegroup', 'site', 'location', ) diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py index f15bc12d494..98862af108e 100644 --- a/netbox/dcim/forms/mixins.py +++ b/netbox/dcim/forms/mixins.py @@ -15,6 +15,7 @@ __all__ = ( 'ScopedBulkEditForm', 'ScopedForm', + 'ScopedImportForm', ) @@ -95,6 +96,7 @@ def __init__(self, *args, **kwargs): except ObjectDoesNotExist: pass + class ScopedImportForm(forms.Form): scope_type = CSVContentTypeField( queryset=ContentType.objects.filter(model__in=LOCATION_SCOPE_TYPES), diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index 8ea695de253..1df3364c4d8 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -35,10 +35,9 @@ def get_config_template(self): class CachedScopeMixin(models.Model): """ - Mixin for adding a GFK Scope to a model that can point to a Region, SiteGroup, Site, or Location. Includes - cached fields for each to allow efficient filtering. Must do any appropriate validation in the clean method - as this does not have any as validation is generally model specific. - + 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', diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index d79d8075c32..4155dacb2ff 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -39,8 +39,12 @@

{% trans "Cluster" %}

- {% trans "Site" %} - {{ object.site|linkify|placeholder }} + {% trans "Scope" %} + {% if object.scope %} + {{ object.scope|linkify }} ({% trans object.scope_type.name %}) + {% else %} + {{ ''|placeholder }} + {% endif %} From d3946d28f2edee6effb116b0e4d24c5dbde45d10 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 1 Nov 2024 14:02:27 -0400 Subject: [PATCH 31/31] Update migrations --- .../migrations/{0043_cluster_scope.py => 0044_cluster_scope.py} | 2 +- ...rs_cached_relations.py => 0045_clusters_cached_relations.py} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename netbox/virtualization/migrations/{0043_cluster_scope.py => 0044_cluster_scope.py} (95%) rename netbox/virtualization/migrations/{0044_clusters_cached_relations.py => 0045_clusters_cached_relations.py} (98%) diff --git a/netbox/virtualization/migrations/0043_cluster_scope.py b/netbox/virtualization/migrations/0044_cluster_scope.py similarity index 95% rename from netbox/virtualization/migrations/0043_cluster_scope.py rename to netbox/virtualization/migrations/0044_cluster_scope.py index 4df32509392..63a888ac33e 100644 --- a/netbox/virtualization/migrations/0043_cluster_scope.py +++ b/netbox/virtualization/migrations/0044_cluster_scope.py @@ -20,7 +20,7 @@ class Migration(migrations.Migration): dependencies = [ ('contenttypes', '0002_remove_content_type_name'), - ('virtualization', '0042_vminterface_vlan_translation_policy'), + ('virtualization', '0043_qinq_svlan'), ] operations = [ diff --git a/netbox/virtualization/migrations/0044_clusters_cached_relations.py b/netbox/virtualization/migrations/0045_clusters_cached_relations.py similarity index 98% rename from netbox/virtualization/migrations/0044_clusters_cached_relations.py rename to netbox/virtualization/migrations/0045_clusters_cached_relations.py index 32c3bd66c95..ff851aa7cb9 100644 --- a/netbox/virtualization/migrations/0044_clusters_cached_relations.py +++ b/netbox/virtualization/migrations/0045_clusters_cached_relations.py @@ -21,7 +21,7 @@ def populate_denormalized_fields(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('virtualization', '0043_cluster_scope'), + ('virtualization', '0044_cluster_scope'), ] operations = [