From 5189b1df190e34443a3dda7587d6e1be468c856b Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Mon, 4 Nov 2024 11:55:58 -0800 Subject: [PATCH] 17929 Add Scope Mixins to Prefix --- netbox/dcim/filterset_mixins.py | 67 ++++++++++++++++++ netbox/dcim/filtersets.py | 58 ---------------- netbox/ipam/filtersets.py | 56 +-------------- netbox/ipam/forms/bulk_edit.py | 30 +------- netbox/ipam/forms/bulk_import.py | 8 +-- netbox/ipam/forms/model_forms.py | 46 +------------ .../0072_prefix_cached_relations.py | 10 +-- netbox/ipam/models/ip.py | 69 +------------------ netbox/virtualization/filtersets.py | 3 +- 9 files changed, 85 insertions(+), 262 deletions(-) create mode 100644 netbox/dcim/filterset_mixins.py diff --git a/netbox/dcim/filterset_mixins.py b/netbox/dcim/filterset_mixins.py new file mode 100644 index 000000000..c007c0120 --- /dev/null +++ b/netbox/dcim/filterset_mixins.py @@ -0,0 +1,67 @@ +import django_filters + +from django.utils.translation import gettext as _ +from netbox.filtersets import BaseFilterSet +from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter +from .models import * + +__all__ = ( + 'ScopedFilterSet', +) + + +class ScopedFilterSet(BaseFilterSet): + """ + Provides additional filtering functionality for location, site, etc.. for Scoped models. + """ + scope_type = ContentTypeFilter() + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='_region', + lookup_expr='in', + label=_('Region (ID)'), + ) + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='_region', + lookup_expr='in', + to_field_name='slug', + label=_('Region (slug)'), + ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='_site_group', + lookup_expr='in', + label=_('Site group (ID)'), + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='_site_group', + lookup_expr='in', + to_field_name='slug', + label=_('Site group (slug)'), + ) + site_id = django_filters.ModelMultipleChoiceFilter( + queryset=Site.objects.all(), + field_name='_site', + label=_('Site (ID)'), + ) + site = django_filters.ModelMultipleChoiceFilter( + field_name='_site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label=_('Site (slug)'), + ) + location_id = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='_location', + lookup_expr='in', + label=_('Location (ID)'), + ) + location = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='_location', + lookup_expr='in', + to_field_name='slug', + label=_('Location (slug)'), + ) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index df66ad77b..0371f882b 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -73,7 +73,6 @@ __all__ = ( 'RearPortFilterSet', 'RearPortTemplateFilterSet', 'RegionFilterSet', - 'ScopedFilterSet', 'SiteFilterSet', 'SiteGroupFilterSet', 'VirtualChassisFilterSet', @@ -2345,60 +2344,3 @@ 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='_site_group', - lookup_expr='in', - label=_('Site group (ID)'), - ) - site_group = TreeNodeMultipleChoiceFilter( - queryset=SiteGroup.objects.all(), - field_name='_site_group', - lookup_expr='in', - to_field_name='slug', - label=_('Site group (slug)'), - ) - site_id = django_filters.ModelMultipleChoiceFilter( - queryset=Site.objects.all(), - field_name='_site', - label=_('Site (ID)'), - ) - site = django_filters.ModelMultipleChoiceFilter( - field_name='_site__slug', - queryset=Site.objects.all(), - to_field_name='slug', - label=_('Site (slug)'), - ) - location_id = TreeNodeMultipleChoiceFilter( - queryset=Location.objects.all(), - field_name='_location', - lookup_expr='in', - label=_('Location (ID)'), - ) - location = TreeNodeMultipleChoiceFilter( - queryset=Location.objects.all(), - field_name='_location', - lookup_expr='in', - to_field_name='slug', - label=_('Location (slug)'), - ) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 88c869a50..5f908af64 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -1,5 +1,6 @@ import django_filters import netaddr +from dcim.filterset_mixins import ScopedFilterSet from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models import Q @@ -9,7 +10,7 @@ from drf_spectacular.utils import extend_schema_field from netaddr.core import AddrFormatError from circuits.models import Provider -from dcim.models import Device, Interface, Location, Region, Site, SiteGroup +from dcim.models import Device, Interface, Region, Site, SiteGroup from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import ( @@ -273,7 +274,7 @@ class RoleFilterSet(OrganizationalModelFilterSet): fields = ('id', 'name', 'slug', 'description', 'weight') -class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): +class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet): family = django_filters.NumberFilter( field_name='prefix', lookup_expr='family' @@ -334,57 +335,6 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='rd', label=_('VRF (RD)'), ) - 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)'), - ) vlan_id = django_filters.ModelMultipleChoiceFilter( queryset=VLAN.objects.all(), label=_('VLAN (ID)'), diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index c323a41c1..7f3216cfd 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import gettext_lazy as _ +from dcim.forms.mixins import ScopedBulkEditForm from dcim.models import Region, Site, SiteGroup from ipam.choices import * from ipam.constants import * @@ -205,20 +206,7 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('description',) -class PrefixBulkEditForm(NetBoxModelBulkEditForm): - scope_type = ContentTypeChoiceField( - queryset=ContentType.objects.filter(model__in=VLANGROUP_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 - ) +class PrefixBulkEditForm(ScopedBulkEditForm, NetBoxModelBulkEditForm): vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, @@ -286,20 +274,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): 'vlan', 'vrf', 'tenant', 'role', 'scope', '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 IPRangeBulkEditForm(NetBoxModelBulkEditForm): vrf = DynamicModelChoiceField( diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 3be4ccc59..184f9d6e0 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ from dcim.models import Device, Interface, Site +from dcim.forms.mixins import ScopedImportForm from ipam.choices import * from ipam.constants import * from ipam.models import * @@ -154,7 +155,7 @@ class RoleImportForm(NetBoxModelImportForm): fields = ('name', 'slug', 'weight', 'description', 'tags') -class PrefixImportForm(NetBoxModelImportForm): +class PrefixImportForm(ScopedImportForm, NetBoxModelImportForm): vrf = CSVModelChoiceField( label=_('VRF'), queryset=VRF.objects.all(), @@ -169,11 +170,6 @@ class PrefixImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Assigned tenant') ) - scope_type = CSVContentTypeField( - queryset=ContentType.objects.filter(model__in=PREFIX_SCOPE_TYPES), - required=False, - label=_('Scope type (app & model)') - ) vlan_group = CSVModelChoiceField( label=_('VLAN group'), queryset=VLANGroup.objects.all(), diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 3d0cd3dd1..56a6dc3d9 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -4,6 +4,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils.translation import gettext_lazy as _ from dcim.models import Device, Interface, Site +from dcim.forms.mixins import ScopedForm from ipam.choices import * from ipam.constants import * from ipam.formfields import IPNetworkFormField @@ -197,25 +198,12 @@ class RoleForm(NetBoxModelForm): ] -class PrefixForm(TenancyForm, NetBoxModelForm): +class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, label=_('VRF') ) - scope_type = ContentTypeChoiceField( - queryset=ContentType.objects.filter(model__in=PREFIX_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 - ) vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, @@ -248,36 +236,6 @@ class PrefixForm(TenancyForm, NetBoxModelForm): 'tenant', '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 - - def clean(self): - super().clean() - - # Assign the selected scope (if any) - self.instance.scope = self.cleaned_data.get('scope') - class IPRangeForm(TenancyForm, NetBoxModelForm): vrf = DynamicModelChoiceField( diff --git a/netbox/ipam/migrations/0072_prefix_cached_relations.py b/netbox/ipam/migrations/0072_prefix_cached_relations.py index 2b457ebda..56841ef4e 100644 --- a/netbox/ipam/migrations/0072_prefix_cached_relations.py +++ b/netbox/ipam/migrations/0072_prefix_cached_relations.py @@ -29,22 +29,22 @@ class Migration(migrations.Migration): migrations.AddField( model_name='prefix', name='_location', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.location'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_%(class)ss', to='dcim.location'), ), migrations.AddField( model_name='prefix', name='_region', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.region'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_%(class)ss', to='dcim.region'), ), migrations.AddField( model_name='prefix', name='_site', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.site'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_%(class)ss', to='dcim.site'), ), migrations.AddField( model_name='prefix', - name='_sitegroup', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.sitegroup'), + name='_site_group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_%(class)ss', to='dcim.sitegroup'), ), # Populate denormalized FK values diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index b17e26169..dcecbcdea 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,5 +1,4 @@ import netaddr -from django.apps import apps from django.contrib.contenttypes.fields import GenericForeignKey from django.core.exceptions import ValidationError from django.db import models @@ -9,6 +8,7 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from core.models import ObjectType +from dcim.models.mixins import CachedScopeMixin from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -198,7 +198,7 @@ class Role(OrganizationalModel): return self.name -class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): +class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, PrimaryModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be scoped to certain areas and/or assigned to VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. @@ -208,22 +208,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): verbose_name=_('prefix'), help_text=_('IPv4 or IPv6 network with mask') ) - scope_type = models.ForeignKey( - to='contenttypes.ContentType', - on_delete=models.PROTECT, - limit_choices_to=Q(model__in=PREFIX_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' - ) vrf = models.ForeignKey( to='ipam.VRF', on_delete=models.PROTECT, @@ -272,36 +256,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): help_text=_("Treat as fully utilized") ) - # Cached associations to enable efficient filtering - _location = models.ForeignKey( - to='dcim.Location', - on_delete=models.CASCADE, - related_name='_prefixes', - blank=True, - null=True - ) - _site = models.ForeignKey( - to='dcim.Site', - on_delete=models.CASCADE, - related_name='_prefixes', - blank=True, - null=True - ) - _region = models.ForeignKey( - to='dcim.Region', - on_delete=models.CASCADE, - related_name='_prefixes', - blank=True, - null=True - ) - _sitegroup = models.ForeignKey( - to='dcim.SiteGroup', - on_delete=models.CASCADE, - related_name='_prefixes', - blank=True, - null=True - ) - # Cached depth & child counts _depth = models.PositiveSmallIntegerField( default=0, @@ -368,25 +322,6 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): 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 - @property def family(self): return self.prefix.version if self.prefix else None diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index ac72bea12..dbcc15a72 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -2,7 +2,8 @@ import django_filters from django.db.models import Q from django.utils.translation import gettext as _ -from dcim.filtersets import CommonInterfaceFilterSet, ScopedFilterSet +from dcim.filtersets import CommonInterfaceFilterSet +from dcim.filterset_mixins import ScopedFilterSet from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate