From e28ed7446c53aecd5b662e6ea7546f571a3393c9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 13 Mar 2026 19:27:26 -0400 Subject: [PATCH] Fixes #21578: Enable assignment of scope object by name when bulk importing prefixes/VLAN groups (#21671) --- netbox/dcim/forms/mixins.py | 41 ++++++++++++- netbox/ipam/forms/bulk_import.py | 12 ++-- netbox/ipam/tests/test_views.py | 68 +++++++++++++++++----- netbox/virtualization/forms/bulk_import.py | 4 +- netbox/virtualization/tests/test_views.py | 20 +++++-- netbox/wireless/forms/bulk_import.py | 2 +- netbox/wireless/tests/test_views.py | 49 +++++++++++----- 7 files changed, 153 insertions(+), 43 deletions(-) diff --git a/netbox/dcim/forms/mixins.py b/netbox/dcim/forms/mixins.py index a7d7a055d..709d61222 100644 --- a/netbox/dcim/forms/mixins.py +++ b/netbox/dcim/forms/mixins.py @@ -121,13 +121,52 @@ class ScopedImportForm(forms.Form): required=False, label=_('Scope type (app & model)') ) + scope_name = forms.CharField( + required=False, + label=_('Scope name'), + help_text=_('Name of the assigned scope object (if not using ID)') + ) def clean(self): super().clean() scope_id = self.cleaned_data.get('scope_id') + scope_name = self.cleaned_data.get('scope_name') scope_type = self.cleaned_data.get('scope_type') - if scope_type and not scope_id: + + # Cannot specify both scope_name and scope_id + if scope_name and scope_id: + raise ValidationError(_("scope_name and scope_id are mutually exclusive.")) + + # Must specify scope_type with scope_name or scope_id + if scope_name and not scope_type: + raise ValidationError(_("scope_type must be specified when using scope_name")) + if scope_id and not scope_type: + raise ValidationError(_("scope_type must be specified when using scope_id")) + + # Look up the scope object by name + if scope_type and scope_name: + model = scope_type.model_class() + try: + scope_obj = model.objects.get(name=scope_name) + except model.DoesNotExist: + raise ValidationError({ + 'scope_name': _('{scope_type} "{name}" not found.').format( + scope_type=bettertitle(model._meta.verbose_name), + name=scope_name + ) + }) + except model.MultipleObjectsReturned: + raise ValidationError({ + 'scope_name': _( + 'Multiple {scope_type} objects match "{name}". Use scope_id to specify the intended object.' + ).format( + scope_type=bettertitle(model._meta.verbose_name), + name=scope_name, + ) + }) + self.cleaned_data['scope_id'] = scope_obj.pk + elif scope_type and not scope_id: raise ValidationError({ 'scope_id': _( "Please select a {scope_type}." diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 2c898f2e0..dd6ac835d 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -210,8 +210,8 @@ class PrefixImportForm(ScopedImportForm, PrimaryModelImportForm): class Meta: model = Prefix fields = ( - 'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_id', - 'is_pool', 'mark_utilized', 'description', 'owner', 'comments', 'tags', + 'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_name', + 'scope_id', 'is_pool', 'mark_utilized', 'description', 'owner', 'comments', 'tags', ) labels = { 'scope_id': _('Scope ID'), @@ -474,7 +474,8 @@ class FHRPGroupImportForm(PrimaryModelImportForm): fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'owner', 'comments', 'tags') -class VLANGroupImportForm(OrganizationalModelImportForm): +class VLANGroupImportForm(ScopedImportForm, OrganizationalModelImportForm): + # Override ScopedImportForm.scope_type to set custom queryset scope_type = CSVContentTypeField( queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), required=False, @@ -494,10 +495,11 @@ class VLANGroupImportForm(OrganizationalModelImportForm): class Meta: model = VLANGroup fields = ( - 'name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'tenant', 'description', 'owner', 'comments', 'tags' + 'name', 'slug', 'scope_type', 'scope_name', 'scope_id', 'vid_ranges', 'tenant', 'description', 'owner', + 'comments', 'tags', ) labels = { - 'scope_id': 'Scope ID', + 'scope_id': _('Scope ID'), } diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index e61287d76..289625493 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -435,13 +435,21 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tags': [t.pk for t in tags], } - site = sites[0].pk - cls.csv_data = ( - "vrf,prefix,status,scope_type,scope_id", - f"VRF 1,10.4.0.0/16,active,dcim.site,{site}", - f"VRF 1,10.5.0.0/16,active,dcim.site,{site}", - f"VRF 1,10.6.0.0/16,active,dcim.site,{site}", - ) + site = sites[0] + cls.csv_data = { + 'default': ( + "vrf,prefix,status,scope_type,scope_id", + f"VRF 1,10.4.0.0/16,active,dcim.site,{site.pk}", + f"VRF 1,10.5.0.0/16,active,dcim.site,{site.pk}", + f"VRF 1,10.6.0.0/16,active,dcim.site,{site.pk}", + ), + 'scope_name': ( + "vrf,prefix,status,scope_type,scope_name", + f"VRF 1,10.4.0.0/16,active,dcim.site,{site.name}", + f"VRF 1,10.5.0.0/16,active,dcim.site,{site.name}", + f"VRF 1,10.6.0.0/16,active,dcim.site,{site.name}", + ), + } cls.csv_update_data = ( "id,description,status", @@ -532,6 +540,32 @@ scope_id: {site.pk} self.assertEqual(prefix.vlan.vid, 101) self.assertEqual(prefix.scope, site) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_prefix_import_with_scope_name(self): + """ + Test YAML-based import using scope_name instead of scope_id. + """ + site = Site.objects.get(name='Site 1') + IMPORT_DATA = """ +prefix: 10.1.3.0/24 +status: active +scope_type: dcim.site +scope_name: Site 1 +""" + # Add all required permissions to the test user + self.add_permissions('ipam.view_prefix', 'ipam.add_prefix') + + form_data = { + 'data': IMPORT_DATA, + 'format': 'yaml' + } + response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True) + self.assertHttpStatus(response, 200) + + prefix = Prefix.objects.get(prefix='10.1.3.0/24') + self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE) + self.assertEqual(prefix.scope, site) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_prefix_import_with_vlan_group(self): """ @@ -884,12 +918,20 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'tags': [t.pk for t in tags], } - cls.csv_data = ( - "name,slug,scope_type,scope_id,description", - "VLAN Group 4,vlan-group-4,,,Fourth VLAN group", - f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group", - f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group", - ) + cls.csv_data = { + 'default': ( + "name,slug,scope_type,scope_id,description", + "VLAN Group 4,vlan-group-4,,,Fourth VLAN group", + f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group", + f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group", + ), + 'scope_name': ( + "name,slug,scope_type,scope_name,description", + "VLAN Group 4,vlan-group-4,,,Fourth VLAN group", + f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].name},Fifth VLAN group", + f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].name},Sixth VLAN group", + ), + } cls.csv_update_data = ( "id,name,description", diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 5cfb5028f..3479f40c0 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -74,8 +74,8 @@ class ClusterImportForm(ScopedImportForm, PrimaryModelImportForm): class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'status', 'scope_type', 'scope_id', 'tenant', 'description', 'owner', 'comments', - 'tags', + 'name', 'type', 'group', 'status', 'scope_type', 'scope_name', 'scope_id', 'tenant', 'description', 'owner', + 'comments', 'tags', ) labels = { 'scope_id': _('Scope ID'), diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index bf029929f..12a4eca4b 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -157,12 +157,20 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tags': [t.pk for t in tags], } - cls.csv_data = ( - "name,type,status", - "Cluster 4,Cluster Type 1,active", - "Cluster 5,Cluster Type 1,active", - "Cluster 6,Cluster Type 1,active", - ) + cls.csv_data = { + 'default': ( + "name,type,status,scope_type,scope_id", + f"Cluster 4,Cluster Type 1,active,dcim.site,{sites[0].pk}", + f"Cluster 5,Cluster Type 1,active,dcim.site,{sites[0].pk}", + f"Cluster 6,Cluster Type 1,active,dcim.site,{sites[0].pk}", + ), + 'scope_name': ( + "name,type,status,scope_type,scope_name", + f"Cluster 4,Cluster Type 1,active,dcim.site,{sites[0].name}", + f"Cluster 5,Cluster Type 1,active,dcim.site,{sites[0].name}", + f"Cluster 6,Cluster Type 1,active,dcim.site,{sites[0].name}", + ), + } cls.csv_update_data = ( "id,name,comments", diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 22f8e6d49..0b637a616 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -76,7 +76,7 @@ class WirelessLANImportForm(ScopedImportForm, PrimaryModelImportForm): model = WirelessLAN fields = ( 'ssid', 'group', 'status', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'scope_type', - 'scope_id', 'description', 'owner', 'comments', 'tags', + 'scope_name', 'scope_id', 'description', 'owner', 'comments', 'tags', ) labels = { 'scope_id': _('Scope ID'), diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py index ef2e24b4f..68e85d4b0 100644 --- a/netbox/wireless/tests/test_views.py +++ b/netbox/wireless/tests/test_views.py @@ -116,23 +116,42 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tags': [t.pk for t in tags], } - cls.csv_data = ( - "group,ssid,status,tenant,scope_type,scope_id", - "Wireless LAN Group 2,WLAN4,{status},{tenant},,".format( - status=WirelessLANStatusChoices.STATUS_ACTIVE, - tenant=tenants[0].name + cls.csv_data = { + 'default': ( + "group,ssid,status,tenant,scope_type,scope_id", + "Wireless LAN Group 2,WLAN4,{status},{tenant},,".format( + status=WirelessLANStatusChoices.STATUS_ACTIVE, + tenant=tenants[0].name + ), + "Wireless LAN Group 2,WLAN5,{status},{tenant},dcim.site,{site}".format( + status=WirelessLANStatusChoices.STATUS_DISABLED, + tenant=tenants[1].name, + site=sites[0].pk + ), + "Wireless LAN Group 2,WLAN6,{status},{tenant},dcim.site,{site}".format( + status=WirelessLANStatusChoices.STATUS_RESERVED, + tenant=tenants[2].name, + site=sites[1].pk + ), ), - "Wireless LAN Group 2,WLAN5,{status},{tenant},dcim.site,{site}".format( - status=WirelessLANStatusChoices.STATUS_DISABLED, - tenant=tenants[1].name, - site=sites[0].pk + 'scope_name': ( + "group,ssid,status,tenant,scope_type,scope_name", + "Wireless LAN Group 2,WLAN4,{status},{tenant},,".format( + status=WirelessLANStatusChoices.STATUS_ACTIVE, + tenant=tenants[0].name + ), + "Wireless LAN Group 2,WLAN5,{status},{tenant},dcim.site,{site}".format( + status=WirelessLANStatusChoices.STATUS_DISABLED, + tenant=tenants[1].name, + site=sites[0].name + ), + "Wireless LAN Group 2,WLAN6,{status},{tenant},dcim.site,{site}".format( + status=WirelessLANStatusChoices.STATUS_RESERVED, + tenant=tenants[2].name, + site=sites[1].name + ), ), - "Wireless LAN Group 2,WLAN6,{status},{tenant},dcim.site,{site}".format( - status=WirelessLANStatusChoices.STATUS_RESERVED, - tenant=tenants[2].name, - site=sites[1].pk - ), - ) + } cls.csv_update_data = ( "id,ssid",