Repository: netbox-community/netbox

Issue:
Specifying a site during the creation or import of a prefix prevents seleting a VLAN unassigned to a site
### NetBox version

v3.5.1

### Python version

3.10

### Steps to Reproduce

Steps for reproducing creating a new Prefix:

1. Login to the netbox Demo site at https://demo.netbox.dev
2. Import the following VLAN from with the YAML format under IPAM > VLANS > VLANs > Import

```yaml
vid: 1000
name: management
status: active
```

3. Create a new Prefix from IPAM > PREFIXES > Prefixes > Add.
4. Set the Prefix field to `10.1.0.0/24`
5. Set the Site field to `DM-Akron`
6. Look for the new management VLAN in the filtered VLAN set. The vlan is not listed when it seems like it should be since it is not assigned to a site

---

Steps for reproducing importing a new Prefix:

1. Login to the netbox Demo site at https://demo.netbox.dev
2. Import the following VLAN from with the YAML format under IPAM > VLANS > VLANs > Import

```yaml
vid: 1000
name: management
status: active
```

3. Import a new Prefix from IPAM > PREFIXES > Prefixes > Import with the following YAML

```yaml
prefix: 10.1.1.0/24
status: active
site: DM-Akron
vlan: 1000
```

### Expected Behavior

For both creation and import, I expect to be able to assign VLANs from the same site as the prefix or VLANs without a site assignment. This is the same expectation found in issue https://github.com/netbox-community/netbox/issues/11619.

### Observed Behavior

- For the creation, any vlan without a site assigned is not available for selection if the prefix is assigned a site. However, If one sets the vlan before setting the site, the management vlan can be set during creation. This work around is not convenient or intuitive to users. 
- For the import a toast pops up in the UI that states `Record 1 vlan: Object not found: 1000`

This is similar to https://github.com/netbox-community/netbox/issues/11619, but it impacts the IPAM app instead of DCIM. I felt that the root cause and fix was different enough that it warranted a separate issue. 

I'm interesting in working this issue if it is approved. 


Discussion:
The simple solution is to switch to the selectors for filtering.

This might require some backend changes through as well.

Resolving patch:
diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py
--- a/netbox/ipam/forms/bulk_import.py
+++ b/netbox/ipam/forms/bulk_import.py
@@ -1,6 +1,7 @@
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
+from django.db.models import Q
 from django.utils.translation import gettext as _
 
 from dcim.models import Device, Interface, Site
@@ -181,16 +182,31 @@ class Meta:
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)
 
-        if data:
+        if not data:
+            return
+
+        site = data.get('site')
+        vlan_group = data.get('vlan_group')
+
+        # Limit VLAN queryset by assigned site and/or group (if specified)
+        query = Q()
+
+        if site:
+            query |= Q(**{
+                f"site__{self.fields['site'].to_field_name}": site
+            })
+            # Don't Forget to include VLANs without a site in the filter
+            query |= Q(**{
+                f"site__{self.fields['site'].to_field_name}__isnull": True
+            })
+
+        if vlan_group:
+            query &= Q(**{
+                f"group__{self.fields['vlan_group'].to_field_name}": vlan_group
+            })
 
-            # Limit VLAN queryset by assigned site and/or group (if specified)
-            params = {}
-            if data.get('site'):
-                params[f"site__{self.fields['site'].to_field_name}"] = data.get('site')
-            if data.get('vlan_group'):
-                params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group')
-            if params:
-                self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params)
+        queryset = self.fields['vlan'].queryset.filter(query)
+        self.fields['vlan'].queryset = queryset
 
 
 class IPRangeImportForm(NetBoxModelImportForm):
diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py
--- a/netbox/ipam/forms/model_forms.py
+++ b/netbox/ipam/forms/model_forms.py
@@ -211,10 +211,8 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
     vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         required=False,
+        selector=True,
         label=_('VLAN'),
-        query_params={
-            'site_id': '$site',
-        }
     )
     role = DynamicModelChoiceField(
         queryset=Role.objects.all(),


Test patch:
diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py
--- a/netbox/ipam/tests/test_views.py
+++ b/netbox/ipam/tests/test_views.py
@@ -495,6 +495,65 @@ def test_prefix_ipaddresses(self):
         url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
         self.assertHttpStatus(self.client.get(url), 200)
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_prefix_import(self):
+        """
+        Custom import test for YAML-based imports (versus CSV)
+        """
+        IMPORT_DATA = """
+prefix: 10.1.1.0/24
+status: active
+vlan: 101
+site: Site 1
+"""
+        # Note, a site is not tied to the VLAN to verify the fix for #12622
+        VLAN.objects.create(vid=101, name='VLAN101')
+
+        # 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_import'), data=form_data, follow=True)
+        self.assertHttpStatus(response, 200)
+
+        prefix = Prefix.objects.get(prefix='10.1.1.0/24')
+        self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
+        self.assertEqual(prefix.vlan.vid, 101)
+        self.assertEqual(prefix.site.name, "Site 1")
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_prefix_import_with_vlan_group(self):
+        """
+        This test covers a unique import edge case where VLAN group is specified during the import.
+        """
+        IMPORT_DATA = """
+prefix: 10.1.2.0/24
+status: active
+vlan: 102
+site: Site 1
+vlan_group: Group 1
+"""
+        vlan_group = VLANGroup.objects.create(name='Group 1', slug='group-1', scope=Site.objects.get(name="Site 1"))
+        VLAN.objects.create(vid=102, name='VLAN102', group=vlan_group)
+
+        # 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_import'), data=form_data, follow=True)
+        self.assertHttpStatus(response, 200)
+
+        prefix = Prefix.objects.get(prefix='10.1.2.0/24')
+        self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
+        self.assertEqual(prefix.vlan.vid, 102)
+        self.assertEqual(prefix.site.name, "Site 1")
+
 
 class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = IPRange