From 9fe6685562d0b6918c7acb6a83678bc399a3fd8a Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Fri, 15 Nov 2024 11:55:46 -0800 Subject: [PATCH 1/2] 17929 Add Scope Mixins to Prefix (#17930) * 17929 Add Scope Mixins to Prefix * 17929 Add Scope Mixins to Prefix * 17929 fixes for tests * 17929 merge latest scope changes * 12596 review changes * 12596 review changes * 12596 review changes * 12596 review changes * 12596 review changes * 12596 review changes * 17929 fix migrations --- netbox/dcim/api/serializers_/sites.py | 8 +-- netbox/dcim/base_filtersets.py | 67 ++++++++++++++++++ netbox/dcim/filtersets.py | 58 ---------------- netbox/dcim/graphql/types.py | 8 +-- netbox/dcim/models/mixins.py | 4 -- netbox/ipam/api/serializers_/ip.py | 5 +- netbox/ipam/apps.py | 2 +- netbox/ipam/constants.py | 5 -- netbox/ipam/filtersets.py | 56 +-------------- netbox/ipam/forms/bulk_edit.py | 30 +------- netbox/ipam/forms/bulk_import.py | 10 +-- netbox/ipam/forms/model_forms.py | 46 +------------ netbox/ipam/graphql/types.py | 2 +- .../0072_prefix_cached_relations.py | 14 ++-- netbox/ipam/models/ip.py | 69 +------------------ netbox/utilities/api.py | 2 +- netbox/virtualization/filtersets.py | 3 +- ...location_alter_cluster__region_and_more.py | 41 +++++++++++ ...l_ordering.py => 0047_natural_ordering.py} | 2 +- netbox/wireless/filtersets.py | 2 +- ...12_alter_wirelesslan__location_and_more.py | 41 +++++++++++ ...l_ordering.py => 0013_natural_ordering.py} | 2 +- 22 files changed, 187 insertions(+), 290 deletions(-) create mode 100644 netbox/dcim/base_filtersets.py create mode 100644 netbox/virtualization/migrations/0046_alter_cluster__location_alter_cluster__region_and_more.py rename netbox/virtualization/migrations/{0046_natural_ordering.py => 0047_natural_ordering.py} (93%) create mode 100644 netbox/wireless/migrations/0012_alter_wirelesslan__location_and_more.py rename netbox/wireless/migrations/{0012_natural_ordering.py => 0013_natural_ordering.py} (82%) diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py index 7cd89e38c..b818cd954 100644 --- a/netbox/dcim/api/serializers_/sites.py +++ b/netbox/dcim/api/serializers_/sites.py @@ -21,7 +21,7 @@ __all__ = ( class RegionSerializer(NestedGroupModelSerializer): parent = NestedRegionSerializer(required=False, allow_null=True, default=None) site_count = serializers.IntegerField(read_only=True, default=0) - prefix_count = RelatedObjectCountField('_prefixes') + prefix_count = RelatedObjectCountField('prefix_set') class Meta: model = Region @@ -35,7 +35,7 @@ class RegionSerializer(NestedGroupModelSerializer): class SiteGroupSerializer(NestedGroupModelSerializer): parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None) site_count = serializers.IntegerField(read_only=True, default=0) - prefix_count = RelatedObjectCountField('_prefixes') + prefix_count = RelatedObjectCountField('prefix_set') class Meta: model = SiteGroup @@ -63,7 +63,7 @@ class SiteSerializer(NetBoxModelSerializer): # Related object counts circuit_count = RelatedObjectCountField('circuit_terminations') device_count = RelatedObjectCountField('devices') - prefix_count = RelatedObjectCountField('_prefixes') + prefix_count = RelatedObjectCountField('prefix_set') rack_count = RelatedObjectCountField('racks') vlan_count = RelatedObjectCountField('vlans') virtualmachine_count = RelatedObjectCountField('virtual_machines') @@ -86,7 +86,7 @@ class LocationSerializer(NestedGroupModelSerializer): tenant = TenantSerializer(nested=True, required=False, allow_null=True) rack_count = serializers.IntegerField(read_only=True, default=0) device_count = serializers.IntegerField(read_only=True, default=0) - prefix_count = RelatedObjectCountField('_prefixes') + prefix_count = RelatedObjectCountField('prefix_set') class Meta: model = Location diff --git a/netbox/dcim/base_filtersets.py b/netbox/dcim/base_filtersets.py new file mode 100644 index 000000000..c007c0120 --- /dev/null +++ b/netbox/dcim/base_filtersets.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/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index fc5f35780..cc1bcac0f 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -461,7 +461,7 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, Organi @strawberry_django.field def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: - return self._clusters.all() + return self.cluster_set.all() @strawberry_django.field def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: @@ -707,7 +707,7 @@ class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @strawberry_django.field def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: - return self._clusters.all() + return self.cluster_set.all() @strawberry_django.field def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: @@ -739,7 +739,7 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje @strawberry_django.field def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: - return self._clusters.all() + return self.cluster_set.all() @strawberry_django.field def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: @@ -763,7 +763,7 @@ class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): @strawberry_django.field def clusters(self) -> List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]: - return self._clusters.all() + return self.cluster_set.all() @strawberry_django.field def circuit_terminations(self) -> List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]]: diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index 1df3364c4..ac4d7dab9 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -59,28 +59,24 @@ class CachedScopeMixin(models.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 ) _site_group = models.ForeignKey( to='dcim.SiteGroup', on_delete=models.CASCADE, - related_name='_%(class)ss', blank=True, null=True ) diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py index 0c3c141af..bfc7ac546 100644 --- a/netbox/ipam/api/serializers_/ip.py +++ b/netbox/ipam/api/serializers_/ip.py @@ -2,8 +2,9 @@ from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from dcim.constants import LOCATION_SCOPE_TYPES from ipam.choices import * -from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, PREFIX_SCOPE_TYPES +from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS from ipam.models import Aggregate, IPAddress, IPRange, Prefix from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.serializers import NetBoxModelSerializer @@ -47,7 +48,7 @@ class PrefixSerializer(NetBoxModelSerializer): vrf = VRFSerializer(nested=True, required=False, allow_null=True) scope_type = ContentTypeField( queryset=ContentType.objects.filter( - model__in=PREFIX_SCOPE_TYPES + model__in=LOCATION_SCOPE_TYPES ), allow_null=True, required=False, diff --git a/netbox/ipam/apps.py b/netbox/ipam/apps.py index e0463dfce..ae88d69a9 100644 --- a/netbox/ipam/apps.py +++ b/netbox/ipam/apps.py @@ -18,7 +18,7 @@ class IPAMConfig(AppConfig): # Register denormalized fields denormalized.register(Prefix, '_site', { '_region': 'region', - '_sitegroup': 'group', + '_site_group': 'group', }) denormalized.register(Prefix, '_location', { '_site': 'site', diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index c07b8441f..6dffd3287 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -23,11 +23,6 @@ VRF_RD_MAX_LENGTH = 21 PREFIX_LENGTH_MIN = 1 PREFIX_LENGTH_MAX = 127 # IPv6 -# models values for ContentTypes which may be Prefix scope types -PREFIX_SCOPE_TYPES = ( - 'region', 'sitegroup', 'site', 'location', -) - # # IPAddresses diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 88c869a50..c762c15fe 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -1,5 +1,6 @@ import django_filters import netaddr +from dcim.base_filtersets 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..7e1382be9 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(), @@ -208,7 +204,7 @@ class PrefixImportForm(NetBoxModelImportForm): 'mark_utilized', 'description', 'comments', 'tags', ) labels = { - 'scope_id': 'Scope ID', + 'scope_id': _('Scope ID'), } def __init__(self, data=None, *args, **kwargs): 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/graphql/types.py b/netbox/ipam/graphql/types.py index 2ef63cf0c..5a4813e0c 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -154,7 +154,7 @@ class IPRangeType(NetBoxObjectType): @strawberry_django.type( models.Prefix, - exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_sitegroup'), + exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_site_group'), filters=PrefixFilter ) class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType): diff --git a/netbox/ipam/migrations/0072_prefix_cached_relations.py b/netbox/ipam/migrations/0072_prefix_cached_relations.py index 2b457ebda..4b438f7d5 100644 --- a/netbox/ipam/migrations/0072_prefix_cached_relations.py +++ b/netbox/ipam/migrations/0072_prefix_cached_relations.py @@ -11,11 +11,11 @@ def populate_denormalized_fields(apps, schema_editor): prefixes = Prefix.objects.filter(site__isnull=False).prefetch_related('site') for prefix in prefixes: prefix._region_id = prefix.site.region_id - prefix._sitegroup_id = prefix.site.group_id + prefix._site_group_id = prefix.site.group_id prefix._site_id = prefix.site_id # Note: Location cannot be set prior to migration - Prefix.objects.bulk_update(prefixes, ['_region', '_sitegroup', '_site']) + Prefix.objects.bulk_update(prefixes, ['_region', '_site_group', '_site']) class Migration(migrations.Migration): @@ -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, 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, 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, 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, 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/utilities/api.py b/netbox/utilities/api.py index 11b914811..6793c0526 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -129,7 +129,7 @@ def get_annotations_for_serializer(serializer_class, fields_to_include=None): for field_name, field in serializer_class._declared_fields.items(): if field_name in fields_to_include and type(field) is RelatedObjectCountField: - related_field = model._meta.get_field(field.relation).field + related_field = getattr(model, field.relation).field annotations[field_name] = count_related(related_field.model, related_field.name) return annotations diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index ac72bea12..ab25492b5 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.base_filtersets import ScopedFilterSet from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate diff --git a/netbox/virtualization/migrations/0046_alter_cluster__location_alter_cluster__region_and_more.py b/netbox/virtualization/migrations/0046_alter_cluster__location_alter_cluster__region_and_more.py new file mode 100644 index 000000000..7b1168da0 --- /dev/null +++ b/netbox/virtualization/migrations/0046_alter_cluster__location_alter_cluster__region_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.0.9 on 2024-11-14 19:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0196_qinq_svlan'), + ('virtualization', '0045_clusters_cached_relations'), + ] + + operations = [ + migrations.AlterField( + model_name='cluster', + name='_location', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location' + ), + ), + migrations.AlterField( + model_name='cluster', + name='_region', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.region' + ), + ), + migrations.AlterField( + model_name='cluster', + name='_site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site'), + ), + migrations.AlterField( + model_name='cluster', + name='_site_group', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.sitegroup' + ), + ), + ] diff --git a/netbox/virtualization/migrations/0046_natural_ordering.py b/netbox/virtualization/migrations/0047_natural_ordering.py similarity index 93% rename from netbox/virtualization/migrations/0046_natural_ordering.py rename to netbox/virtualization/migrations/0047_natural_ordering.py index 9284b6331..4454cfe2d 100644 --- a/netbox/virtualization/migrations/0046_natural_ordering.py +++ b/netbox/virtualization/migrations/0047_natural_ordering.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('virtualization', '0045_clusters_cached_relations'), + ('virtualization', '0046_alter_cluster__location_alter_cluster__region_and_more'), ('dcim', '0197_natural_sort_collation'), ] diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 5a4195e6c..cc5aefbd8 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -2,7 +2,7 @@ import django_filters from django.db.models import Q from dcim.choices import LinkStatusChoices -from dcim.filtersets import ScopedFilterSet +from dcim.base_filtersets import ScopedFilterSet from dcim.models import Interface from ipam.models import VLAN from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet diff --git a/netbox/wireless/migrations/0012_alter_wirelesslan__location_and_more.py b/netbox/wireless/migrations/0012_alter_wirelesslan__location_and_more.py new file mode 100644 index 000000000..7edaff92b --- /dev/null +++ b/netbox/wireless/migrations/0012_alter_wirelesslan__location_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.0.9 on 2024-11-14 19:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0196_qinq_svlan'), + ('wireless', '0011_wirelesslan__location_wirelesslan__region_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='wirelesslan', + name='_location', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location' + ), + ), + migrations.AlterField( + model_name='wirelesslan', + name='_region', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.region' + ), + ), + migrations.AlterField( + model_name='wirelesslan', + name='_site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site'), + ), + migrations.AlterField( + model_name='wirelesslan', + name='_site_group', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.sitegroup' + ), + ), + ] diff --git a/netbox/wireless/migrations/0012_natural_ordering.py b/netbox/wireless/migrations/0013_natural_ordering.py similarity index 82% rename from netbox/wireless/migrations/0012_natural_ordering.py rename to netbox/wireless/migrations/0013_natural_ordering.py index da818bdd9..e33c87c60 100644 --- a/netbox/wireless/migrations/0012_natural_ordering.py +++ b/netbox/wireless/migrations/0013_natural_ordering.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('wireless', '0011_wirelesslan__location_wirelesslan__region_and_more'), + ('wireless', '0012_alter_wirelesslan__location_and_more'), ('dcim', '0197_natural_sort_collation'), ] From b4f15092dbb849ee85fd2dd8caf988ea4bd7eadb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Nov 2024 14:44:57 -0500 Subject: [PATCH 2/2] Closes #5858: Implement a quick-add UI widget for related objects (#18016) * WIP * Misc cleanup * Add warning re: nested quick-adds --- netbox/circuits/forms/model_forms.py | 14 +++-- netbox/dcim/forms/model_forms.py | 21 +++++--- netbox/ipam/forms/model_forms.py | 14 +++-- netbox/netbox/views/generic/object_views.py | 41 +++++++++++---- netbox/project-static/dist/netbox.js | Bin 390388 -> 390918 bytes netbox/project-static/dist/netbox.js.map | Bin 527142 -> 527957 bytes netbox/project-static/src/buttons/reslug.ts | 48 +++++++++--------- netbox/project-static/src/htmx.ts | 11 ++-- netbox/project-static/src/quickAdd.ts | 39 ++++++++++++++ netbox/templates/htmx/quick_add.html | 28 ++++++++++ netbox/templates/htmx/quick_add_created.html | 22 ++++++++ netbox/tenancy/forms/forms.py | 1 + netbox/utilities/forms/fields/dynamic.py | 12 ++++- .../templates/widgets/apiselect.html | 27 +++++++--- netbox/virtualization/forms/model_forms.py | 6 ++- netbox/vpn/forms/model_forms.py | 9 ++-- netbox/wireless/forms/model_forms.py | 3 +- 17 files changed, 228 insertions(+), 68 deletions(-) create mode 100644 netbox/project-static/src/quickAdd.ts create mode 100644 netbox/templates/htmx/quick_add.html create mode 100644 netbox/templates/htmx/quick_add_created.html diff --git a/netbox/circuits/forms/model_forms.py b/netbox/circuits/forms/model_forms.py index 10cd06563..9eeb0f588 100644 --- a/netbox/circuits/forms/model_forms.py +++ b/netbox/circuits/forms/model_forms.py @@ -50,7 +50,9 @@ class ProviderForm(NetBoxModelForm): class ProviderAccountForm(NetBoxModelForm): provider = DynamicModelChoiceField( label=_('Provider'), - queryset=Provider.objects.all() + queryset=Provider.objects.all(), + selector=True, + quick_add=True ) comments = CommentField() @@ -64,7 +66,9 @@ class ProviderAccountForm(NetBoxModelForm): class ProviderNetworkForm(NetBoxModelForm): provider = DynamicModelChoiceField( label=_('Provider'), - queryset=Provider.objects.all() + queryset=Provider.objects.all(), + selector=True, + quick_add=True ) comments = CommentField() @@ -97,7 +101,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm): provider = DynamicModelChoiceField( label=_('Provider'), queryset=Provider.objects.all(), - selector=True + selector=True, + quick_add=True ) provider_account = DynamicModelChoiceField( label=_('Provider account'), @@ -108,7 +113,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm): } ) type = DynamicModelChoiceField( - queryset=CircuitType.objects.all() + queryset=CircuitType.objects.all(), + quick_add=True ) comments = CommentField() diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 2fcdbe5fd..b004798af 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -112,12 +112,14 @@ class SiteForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( label=_('Region'), queryset=Region.objects.all(), - required=False + required=False, + quick_add=True ) group = DynamicModelChoiceField( label=_('Group'), queryset=SiteGroup.objects.all(), - required=False + required=False, + quick_add=True ) asns = DynamicModelMultipleChoiceField( queryset=ASN.objects.all(), @@ -206,7 +208,8 @@ class RackRoleForm(NetBoxModelForm): class RackTypeForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), - queryset=Manufacturer.objects.all() + queryset=Manufacturer.objects.all(), + quick_add=True ) comments = CommentField() slug = SlugField( @@ -348,7 +351,8 @@ class ManufacturerForm(NetBoxModelForm): class DeviceTypeForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), - queryset=Manufacturer.objects.all() + queryset=Manufacturer.objects.all(), + quick_add=True ) default_platform = DynamicModelChoiceField( label=_('Default platform'), @@ -436,7 +440,8 @@ class PlatformForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( label=_('Manufacturer'), queryset=Manufacturer.objects.all(), - required=False + required=False, + quick_add=True ) config_template = DynamicModelChoiceField( label=_('Config template'), @@ -508,7 +513,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm): ) role = DynamicModelChoiceField( label=_('Device role'), - queryset=DeviceRole.objects.all() + queryset=DeviceRole.objects.all(), + quick_add=True ) platform = DynamicModelChoiceField( label=_('Platform'), @@ -750,7 +756,8 @@ class PowerFeedForm(TenancyForm, NetBoxModelForm): power_panel = DynamicModelChoiceField( label=_('Power panel'), queryset=PowerPanel.objects.all(), - selector=True + selector=True, + quick_add=True ) rack = DynamicModelChoiceField( label=_('Rack'), diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 56a6dc3d9..53ffe8f3f 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -109,7 +109,8 @@ class RIRForm(NetBoxModelForm): class AggregateForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), - label=_('RIR') + label=_('RIR'), + quick_add=True ) comments = CommentField() @@ -132,6 +133,7 @@ class ASNRangeForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), label=_('RIR'), + quick_add=True ) slug = SlugField() fieldsets = ( @@ -150,6 +152,7 @@ class ASNForm(TenancyForm, NetBoxModelForm): rir = DynamicModelChoiceField( queryset=RIR.objects.all(), label=_('RIR'), + quick_add=True ) sites = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), @@ -216,7 +219,8 @@ class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm): role = DynamicModelChoiceField( label=_('Role'), queryset=Role.objects.all(), - required=False + required=False, + quick_add=True ) comments = CommentField() @@ -246,7 +250,8 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): role = DynamicModelChoiceField( label=_('Role'), queryset=Role.objects.all(), - required=False + required=False, + quick_add=True ) comments = CommentField() @@ -639,7 +644,8 @@ class VLANForm(TenancyForm, NetBoxModelForm): role = DynamicModelChoiceField( label=_('Role'), queryset=Role.objects.all(), - required=False + required=False, + quick_add=True ) qinq_svlan = DynamicModelChoiceField( label=_('Q-in-Q SVLAN'), diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 0686e52b7..fb554ca4f 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -233,18 +233,23 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): form = self.form(instance=obj, initial=initial_data) restrict_form_fields(form, request.user) - # If this is an HTMX request, return only the rendered form HTML - if htmx_partial(request): - return render(request, self.htmx_template_name, { - 'model': model, - 'object': obj, - 'form': form, - }) - - return render(request, self.template_name, { + context = { 'model': model, 'object': obj, 'form': form, + } + + # If the form is being displayed within a "quick add" widget, + # use the appropriate template + if request.GET.get('_quickadd'): + return render(request, 'htmx/quick_add.html', context) + + # If this is an HTMX request, return only the rendered form HTML + if htmx_partial(request): + return render(request, self.htmx_template_name, context) + + return render(request, self.template_name, { + **context, 'return_url': self.get_return_url(request, obj), 'prerequisite_model': get_prerequisite_model(self.queryset), **self.get_extra_context(request, obj), @@ -259,6 +264,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): """ logger = logging.getLogger('netbox.views.ObjectEditView') obj = self.get_object(**kwargs) + model = self.queryset.model # Take a snapshot for change logging (if editing an existing object) if obj.pk and hasattr(obj, 'snapshot'): @@ -292,6 +298,12 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): msg = f'{msg} {obj}' messages.success(request, msg) + # Object was created via "quick add" modal + if '_quickadd' in request.POST: + return render(request, 'htmx/quick_add_created.html', { + 'object': obj, + }) + # If adding another object, redirect back to the edit form if '_addanother' in request.POST: redirect_url = request.path @@ -324,12 +336,19 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): else: logger.debug("Form validation failed") - return render(request, self.template_name, { + context = { + 'model': model, 'object': obj, 'form': form, 'return_url': self.get_return_url(request, obj), **self.get_extra_context(request, obj), - }) + } + + # Form was submitted via a "quick add" widget + if '_quickadd' in request.POST: + return render(request, 'htmx/quick_add.html', context) + + return render(request, self.template_name, context) class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 969d5c73a704424190120ac1a6c96af13085b025..5e24ee6250e4ad778886c374c7ca972faf2e3ce6 100644 GIT binary patch delta 9318 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 diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 0f4ac63d3d6199f281667fc18788c7e456278a22..a05e8edb51d286b535fab2371e7b9ad18b49d4b0 100644 GIT binary patch delta 1286 zcmZWpJ#W)s5LSw*jTrb+DX0ulr3}zlR2@J9!Ek=#q-ij&6@_4glsd7Sx^E9>&(c>DM!yUQGD~Q@^N4T)TA&0no<-27IfhQ_`(1v3JLPHAxZ!y z=d1!%)I=G>Ivq=#v@rmtsDKRhNwJfsin3@Tv#0`8ky0))Pf1dl%di3AUZ!w0##Mz& z6?|iyL2S+C6sHAX88oL&H#!+%p34HA>&6^48W}md`4XXBycvx=i74PS@$sIrlqtj) zhRTe(N(FW$UKlFb%xe5-sNA?*q*#>S_gO?HPRmtf+Gu)s*0l9)Lb)Iwm3f&BzHvGbaV$bal? z^3JO9!J0C=!c+Oly0Ak(WxJEM?7t4he@4Rw@#7hFae!O5pt!9Ad1NJ9=2fF?B*$Qh zXyuB)x2z&b>r=D@=wu|j8ly08FJlxclnN8ecweZL3{5<9032djV&Ma{414(G@@7hl z&2O10sJcL;E@AqTSY?4RCYex1ps`M9TqPLRWOF;bxhSaOR}Lx;olApOwwx4V?W#|w@L2vG)GX?(ktaEW(*0i}eI8x8YZ#3<`c2XLDIHz47ebuza F)E}CVZ*>3w delta 593 zcmYjNO-lk%6h)nKC>NEq5DG+a_dp_|O%ZqAjFZYVO(tTrF@4C8jN+)ZF+nF>w{bQt zBeyMrS@#2?RkZ2{v})b1_ugc>xR=ZQIOp8+ejk4v$DeA+VJ|uAB?B~qG(ui4DZHEq zTJFI>c<5&UG>lY$63PSQ5dk26r2yW=d{mwr82|>NBTHsS85!jODoCSU%t}HN?qZ~a zl1Bg7#WY(kpfW(&PZS}ARiyUW^^BO}XflQ89H7uj+3|@udQD;+8p43DCB&t$3uI#e z8#7r=nQFmlx}m^~-;}a*0*U}dw%d|($bB0Mc=OPo8h|grFDb0SZw%c{IYK;;b&nlk zV~vX`@PDV{8%Ib4ad$Lv1R4w8#992j>u*cI|5BKWD~U-?amhvgi9s9i=Pvh{_)hcO zkftr_M2Sh=QjeIf9Q*5(CQawyIs-SS;M_)2;vM%g7G4jWd4pJZ|2djANxhmt@q}IP z)TB('button#reslug')) { + const form = slugButton.form; + if (form == null) continue; + const slugField = form.querySelector('#id_slug') as HTMLInputElement; + if (slugField == null) continue; + const sourceId = slugField.getAttribute('slug-source'); + const sourceField = form.querySelector(`#id_${sourceId}`) as HTMLInputElement; - if (sourceField === null) { - console.error('Unable to find field for slug field.'); - return; - } + const slugLengthAttr = slugField.getAttribute('maxlength'); + let slugLength = 50; - const slugLengthAttr = slugField.getAttribute('maxlength'); - let slugLength = 50; - - if (slugLengthAttr) { - slugLength = Number(slugLengthAttr); - } - sourceField.addEventListener('blur', () => { - if (!slugField.value) { - slugField.value = slugify(sourceField.value, slugLength); + if (slugLengthAttr) { + slugLength = Number(slugLengthAttr); } - }); - slugButton.addEventListener('click', () => { - slugField.value = slugify(sourceField.value, slugLength); - }); + sourceField.addEventListener('blur', () => { + if (!slugField.value) { + slugField.value = slugify(sourceField.value, slugLength); + } + }); + slugButton.addEventListener('click', () => { + slugField.value = slugify(sourceField.value, slugLength); + }); + } } diff --git a/netbox/project-static/src/htmx.ts b/netbox/project-static/src/htmx.ts index f4092036b..6a772011b 100644 --- a/netbox/project-static/src/htmx.ts +++ b/netbox/project-static/src/htmx.ts @@ -4,11 +4,16 @@ import { initSelects } from './select'; import { initObjectSelector } from './objectSelector'; import { initBootstrap } from './bs'; import { initMessages } from './messages'; +import { initQuickAdd } from './quickAdd'; function initDepedencies(): void { - for (const init of [initButtons, initClipboard, initSelects, initObjectSelector, initBootstrap, initMessages]) { - init(); - } + initButtons(); + initClipboard(); + initSelects(); + initObjectSelector(); + initQuickAdd(); + initBootstrap(); + initMessages(); } /** diff --git a/netbox/project-static/src/quickAdd.ts b/netbox/project-static/src/quickAdd.ts new file mode 100644 index 000000000..e038f5d19 --- /dev/null +++ b/netbox/project-static/src/quickAdd.ts @@ -0,0 +1,39 @@ +import { Modal } from 'bootstrap'; + +function handleQuickAddObject(): void { + const quick_add = document.getElementById('quick-add-object'); + if (quick_add == null) return; + + const object_id = quick_add.getAttribute('data-object-id'); + if (object_id == null) return; + const object_repr = quick_add.getAttribute('data-object-repr'); + if (object_repr == null) return; + + const target_id = quick_add.getAttribute('data-target-id'); + if (target_id == null) return; + const target = document.getElementById(target_id); + if (target == null) return; + + //@ts-expect-error tomselect added on init + target.tomselect.addOption({ + id: object_id, + display: object_repr, + }); + //@ts-expect-error tomselect added on init + target.tomselect.addItem(object_id); + + const modal_element = document.getElementById('htmx-modal'); + if (modal_element) { + const modal = Modal.getInstance(modal_element); + if (modal) { + modal.hide(); + } + } +} + +export function initQuickAdd(): void { + const quick_add_modal = document.getElementById('htmx-modal-content'); + if (quick_add_modal) { + quick_add_modal.addEventListener('htmx:afterSwap', () => handleQuickAddObject()); + } +} diff --git a/netbox/templates/htmx/quick_add.html b/netbox/templates/htmx/quick_add.html new file mode 100644 index 000000000..9473e14a1 --- /dev/null +++ b/netbox/templates/htmx/quick_add.html @@ -0,0 +1,28 @@ +{% load form_helpers %} +{% load helpers %} +{% load i18n %} + + + diff --git a/netbox/templates/htmx/quick_add_created.html b/netbox/templates/htmx/quick_add_created.html new file mode 100644 index 000000000..3b1a24c48 --- /dev/null +++ b/netbox/templates/htmx/quick_add_created.html @@ -0,0 +1,22 @@ +{% load form_helpers %} +{% load helpers %} +{% load i18n %} + + + diff --git a/netbox/tenancy/forms/forms.py b/netbox/tenancy/forms/forms.py index 114253e7a..0edb36348 100644 --- a/netbox/tenancy/forms/forms.py +++ b/netbox/tenancy/forms/forms.py @@ -25,6 +25,7 @@ class TenancyForm(forms.Form): label=_('Tenant'), queryset=Tenant.objects.all(), required=False, + quick_add=True, query_params={ 'group_id': '$tenant_group' } diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py index 6666c0e4d..13d5ffc70 100644 --- a/netbox/utilities/forms/fields/dynamic.py +++ b/netbox/utilities/forms/fields/dynamic.py @@ -2,7 +2,7 @@ import django_filters from django import forms from django.conf import settings from django.forms import BoundField -from django.urls import reverse +from django.urls import reverse, reverse_lazy from utilities.forms import widgets from utilities.views import get_viewname @@ -66,6 +66,8 @@ class DynamicModelChoiceMixin: choice (DEPRECATED: pass `context={'disabled': '$fieldname'}` instead) context: A mapping of