Repository: nautobot/nautobot

Issue:
Unable to assign tagged VLANs to interfaces when VLAN is in multiple locations
### Environment
* Nautobot version (Docker tag too if applicable): 2.2.0
* Docker tag: ghcr.io/nautobot/nautobot:2.2.0-py3.9
* Python version: 3.9.19
* Database platform, version: AWS Elasticache

### Steps to Reproduce
1. Create a VLAN
2. Assign the multiple locations to the VLAN (new 2.2 feature)
3. Go to any device and attempt to assign the VLAN as a **tagged** to any interface


<!-- What did you expect to happen? -->
### Expected Behavior
Return to the interface page and no error

<!-- What happened instead? -->
### Observed Behavior
![image](https://github.com/nautobot/nautobot/assets/72052134/14cb03e5-c2a8-47fc-bfcf-817a235549ed)

Likewise, another error occurs if i do the following

1. Remove the multiple locations on the VLAN (just have one location)
2. Assign VLAN to any interface
3. VLAN gets successfully added, however i am no longer able to access the interface, this error pops up

```
<class 'django.urls.exceptions.NoReverseMatch'>

Reverse for 'location' with no arguments not found. 1 pattern(s) tried: ['dcim/locations/(?P<pk>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/\\Z']

Python version: 3.9.19
Nautobot version: 2.2.0
```

I saw #5513 but I'm not sure if this is related

Another possibly related error:

When I click on the hyperlinked number of locations in the VLANs page (`nautobot.com/ipam/vlans/`) 
![image](https://github.com/nautobot/nautobot/assets/72052134/136bd8e5-f9b1-4045-b5b1-ef54401839e2)

it returns the following error (regardless if 1 or multiple locations are assigned to the VLAN) 

```
<class 'ValueError'>

Field 'vid' expected a number but got 'c5b1ca60-134f-48d2-b8ce-4d19d5920fff'.

Python version: 3.9.19
Nautobot version: 2.2.0




Discussion:
I also saw v2.2.1 patchnotes which mentions

> https://github.com/nautobot/nautobot/issues/5009 - Added the option to filter objects with select/multi-select custom fields based on the UUID of the defined custom field choice(s), for example /api/dcim/locations/?cf_multiselect=1ea9237c-3ba7-4985-ba7e-6fd9e9bff813 as an alternative to /api/dcim/locations/?cf_multiselect=some-choice-value.

Is this a fix for the above issue? It doesn't seem to be from a glance
Thanks for the report! #5513 is also a new bug but isn't the same issue as this, and #5009 is about custom fields not interfaces/VLANs, so this is a new issue.
The third symptom you reported looks like #5123 / #4229.

Resolving patch:
diff --git a/nautobot/dcim/forms.py b/nautobot/dcim/forms.py
--- a/nautobot/dcim/forms.py
+++ b/nautobot/dcim/forms.py
@@ -55,7 +55,7 @@
 )
 from nautobot.extras.models import ExternalIntegration, SecretsGroup, Status
 from nautobot.ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
-from nautobot.ipam.models import IPAddress, IPAddressToInterface, VLAN, VRF
+from nautobot.ipam.models import IPAddress, IPAddressToInterface, VLAN, VLANLocationAssignment, VRF
 from nautobot.tenancy.forms import TenancyFilterForm, TenancyForm
 from nautobot.tenancy.models import Tenant, TenantGroup
 from nautobot.virtualization.models import Cluster, ClusterGroup, VirtualMachine
@@ -194,8 +194,13 @@ def clean(self):
         # TODO: after Location model replaced Site, which was not a hierarchical model, should we allow users to add a VLAN
         # belongs to the parent Location or the child location of the parent device to the `tagged_vlan` field of the interface?
         elif mode == InterfaceModeChoices.MODE_TAGGED:
-            valid_locations = [None, self.cleaned_data[parent_field].location]
-            invalid_vlans = [str(v) for v in tagged_vlans if v.location not in valid_locations]
+            valid_location = self.cleaned_data[parent_field].location
+            invalid_vlans = [
+                str(v)
+                for v in tagged_vlans
+                if v.locations.without_tree_fields().exists()
+                and not VLANLocationAssignment.objects.filter(location=valid_location, vlan=v).exists()
+            ]
 
             if invalid_vlans:
                 raise forms.ValidationError(
diff --git a/nautobot/ipam/tables.py b/nautobot/ipam/tables.py
--- a/nautobot/ipam/tables.py
+++ b/nautobot/ipam/tables.py
@@ -771,7 +771,7 @@ class InterfaceVLANTable(StatusTableMixin, BaseTable):
     tenant = TenantColumn()
     role = tables.TemplateColumn(template_code=VLAN_ROLE_LINK)
     location_count = LinkedCountColumn(
-        viewname="dcim:location",
+        viewname="dcim:location_list",
         url_params={"vlans": "pk"},
         verbose_name="Locations",
     )


Test patch:
diff --git a/nautobot/dcim/tests/test_forms.py b/nautobot/dcim/tests/test_forms.py
--- a/nautobot/dcim/tests/test_forms.py
+++ b/nautobot/dcim/tests/test_forms.py
@@ -1,8 +1,8 @@
 from django.test import TestCase
 
 from nautobot.core.testing.forms import FormTestCases
-from nautobot.dcim.choices import DeviceFaceChoices, InterfaceTypeChoices, RackWidthChoices
-from nautobot.dcim.forms import DeviceFilterForm, DeviceForm, InterfaceCreateForm, RackForm
+from nautobot.dcim.choices import DeviceFaceChoices, InterfaceModeChoices, InterfaceTypeChoices, RackWidthChoices
+from nautobot.dcim.forms import DeviceFilterForm, DeviceForm, InterfaceCreateForm, InterfaceForm, RackForm
 from nautobot.dcim.models import (
     Device,
     DeviceType,
@@ -14,6 +14,7 @@
     Rack,
 )
 from nautobot.extras.models import Role, SecretsGroup, Status
+from nautobot.ipam.models import VLAN
 from nautobot.tenancy.models import Tenant
 from nautobot.virtualization.models import Cluster, ClusterGroup, ClusterType
 
@@ -263,3 +264,49 @@ def test_update_rack_location(self):
         }
         form = RackForm(data=data, instance=racks[0])
         self.assertTrue(form.is_valid())
+
+
+class InterfaceTestCase(TestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.device = Device.objects.first()
+        status = Status.objects.get_for_model(Interface).first()
+        cls.interface = Interface.objects.create(
+            device=cls.device,
+            name="test interface form 0.0",
+            type=InterfaceTypeChoices.TYPE_2GFC_SFP,
+            status=status,
+        )
+        cls.vlan = VLAN.objects.first()
+        cls.data = {
+            "device": cls.device.pk,
+            "name": "test interface form 0.0",
+            "type": InterfaceTypeChoices.TYPE_2GFC_SFP,
+            "status": status.pk,
+            "mode": InterfaceModeChoices.MODE_TAGGED,
+            "tagged_vlans": [cls.vlan.pk],
+        }
+
+    def test_interface_form_clean_vlan_location_fail(self):
+        """Assert that form validation fails when no matching locations are associated to tagged VLAN"""
+        self.vlan.locations.set(list(Location.objects.exclude(pk=self.device.location.pk))[:2])
+        form = InterfaceForm(data=self.data, instance=self.interface)
+        self.assertFalse(form.is_valid())
+
+    def test_interface_vlan_location_clean_multiple_locations_pass(self):
+        """Assert that form validation passes when multiple locations are associated to tagged VLAN with one matching"""
+        self.vlan.locations.add(self.device.location)
+        form = InterfaceForm(data=self.data, instance=self.interface)
+        self.assertTrue(form.is_valid())
+
+    def test_interface_vlan_location_clean_single_location_pass(self):
+        """Assert that form validation passes when a single location is associated to tagged VLAN"""
+        self.vlan.locations.set([self.device.location])
+        form = InterfaceForm(data=self.data, instance=self.interface)
+        self.assertTrue(form.is_valid())
+
+    def test_interface_vlan_location_clean_no_locations_pass(self):
+        """Assert that form validation passes when no locations are associated to tagged VLAN"""
+        self.vlan.locations.clear()
+        form = InterfaceForm(data=self.data, instance=self.interface)
+        self.assertTrue(form.is_valid())