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

This commit is contained in:
Jeremy Stretch
2026-03-13 19:27:26 -04:00
committed by GitHub
parent 9b57512b12
commit e28ed7446c
7 changed files with 153 additions and 43 deletions

View File

@@ -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}."

View File

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

View File

@@ -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",

View File

@@ -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'),

View File

@@ -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",

View File

@@ -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'),

View File

@@ -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",