Parcourir la source

Fixes #21578: Enable assignment of scope object by name when bulk importing prefixes/VLAN groups (#21671)

Jeremy Stretch il y a 1 jour
Parent
commit
e28ed7446c

+ 40 - 1
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}."

+ 7 - 5
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'),
         }
 
 

+ 55 - 13
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",

+ 2 - 2
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'),

+ 14 - 6
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",

+ 1 - 1
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'),

+ 34 - 15
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
-            ),
-            "Wireless LAN Group 2,WLAN5,{status},{tenant},dcim.site,{site}".format(
-                status=WirelessLANStatusChoices.STATUS_DISABLED,
-                tenant=tenants[1].name,
-                site=sites[0].pk
+        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,WLAN6,{status},{tenant},dcim.site,{site}".format(
-                status=WirelessLANStatusChoices.STATUS_RESERVED,
-                tenant=tenants[2].name,
-                site=sites[1].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
+                ),
             ),
-        )
+        }
 
         cls.csv_update_data = (
             "id,ssid",