Repository: nautobot/nautobot

Issue:
Incorrect GraphQL tooltip when filtering cables by site
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for reporting reproducible bugs. If you need assistance
    with Nautobot installation, or if you have a general question, please start a
    discussion instead: https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report, and that any plugins have been disabled.
-->
### Environment
* Python version: 3.8.5
* Nautobot version: v1.0.0b5

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current stable release of Nautobot. Begin with the
    creation of any necessary database objects and call out every operation
    being performed explicitly. If reporting a bug in the REST API, be sure to
    reconstruct the raw HTTP request(s) being made: Don't rely on a client
    library such as pynautobot.
-->
This bug was introduced with the fix from PR #360.  When querying for cables and filtering by site, the tooltip should say something like:

```
site: [String] [Slug]
```
However the tooltip actually says 

```
site: [String] [invalid name]
```

### Steps to Reproduce
1. In the latest `develop` branch, open GraphQL in the UI
2. Run a query on cables, like below, and hover over "site"
```
{
    cables(site: "<site-slug>") {
        id
    }
}
```

<!-- What did you expect to happen? -->
### Expected Behavior
The tooltip for "site" should state that the slug is needed

<!-- What happened instead? -->
### Observed Behavior
The tooltip for "site" shows an "invalid name"
<img width="220" alt="Screen Shot 2021-04-22 at 5 25 08 PM" src="https://user-images.githubusercontent.com/536467/115788367-83197080-a391-11eb-9695-62ffe8945008.png">




Discussion:
So we did a little digging and it turns out that for any filterset field that is a method field (such as `CableFilterSet.site`) a `label` must also be set, or the text will come back as `[invalid name]`. 

Fortunately it appears there aren't a huge amount of them:

```
$ egrep -r '= .*Filter\(method' nautobot | grep -v label                                      <<<
nautobot/dcim/filters.py:    device_id = MultiValueCharFilter(method="filter_device")
nautobot/dcim/filters.py:    device = MultiValueCharFilter(method="filter_device", field_name="device__name")
nautobot/dcim/filters.py:    rack_id = MultiValueCharFilter(method="filter_device", field_name="device__rack_id")
nautobot/dcim/filters.py:    rack = MultiValueCharFilter(method="filter_device", field_name="device__rack__name")
nautobot/dcim/filters.py:    site_id = MultiValueCharFilter(method="filter_device", field_name="device__site_id")
nautobot/dcim/filters.py:    site = MultiValueCharFilter(method="filter_device", field_name="device__site__slug")
nautobot/dcim/filters.py:    tenant_id = MultiValueCharFilter(method="filter_device", field_name="device__tenant_id")
nautobot/dcim/filters.py:    tenant = MultiValueCharFilter(method="filter_device", field_name="device__tenant__slug")
nautobot/dcim/filters.py:    device_id = MultiValueCharFilter(method="filter_device")
nautobot/dcim/filters.py:    device = MultiValueCharFilter(method="filter_device", field_name="device__name")
nautobot/dcim/filters.py:    device_id = MultiValueCharFilter(method="filter_device")
nautobot/dcim/filters.py:    device = MultiValueCharFilter(method="filter_device", field_name="device__name")
nautobot/dcim/filters.py:    device_id = MultiValueCharFilter(method="filter_device")
nautobot/dcim/filters.py:    device = MultiValueCharFilter(method="filter_device", field_name="device__name")
```

Resolving patch:
diff --git a/nautobot/dcim/filters.py b/nautobot/dcim/filters.py
--- a/nautobot/dcim/filters.py
+++ b/nautobot/dcim/filters.py
@@ -826,7 +826,7 @@ class CableTerminationFilterSet(django_filters.FilterSet):
 
 
 class PathEndpointFilterSet(django_filters.FilterSet):
-    connected = django_filters.BooleanFilter(method="filter_connected")
+    connected = django_filters.BooleanFilter(method="filter_connected", label="Connected status (bool)")
 
     def filter_connected(self, queryset, name, value):
         if value:
@@ -902,9 +902,9 @@ class InterfaceFilterSet(
     device = MultiValueCharFilter(
         method="filter_device",
         field_name="name",
-        label="Device",
+        label="Device (name)",
     )
-    device_id = MultiValueNumberFilter(
+    device_id = MultiValueCharFilter(
         method="filter_device_id",
         field_name="pk",
         label="Device (ID)",
@@ -921,7 +921,7 @@ class InterfaceFilterSet(
     mac_address = MultiValueMACAddressFilter()
     tag = TagFilter()
     vlan_id = django_filters.CharFilter(method="filter_vlan_id", label="Assigned VLAN")
-    vlan = django_filters.CharFilter(method="filter_vlan", label="Assigned VID")
+    vlan = django_filters.NumberFilter(method="filter_vlan", label="Assigned VID")
     type = django_filters.MultipleChoiceFilter(choices=InterfaceTypeChoices, null_value=None)
 
     class Meta:
@@ -965,7 +965,7 @@ def filter_vlan_id(self, queryset, name, value):
         return queryset.filter(Q(untagged_vlan_id=value) | Q(tagged_vlans=value))
 
     def filter_vlan(self, queryset, name, value):
-        value = value.strip()
+        value = str(value).strip()
         if not value:
             return queryset
         return queryset.filter(Q(untagged_vlan_id__vid=value) | Q(tagged_vlans__vid=value))
@@ -1138,14 +1138,14 @@ class CableFilterSet(StatusModelFilterSetMixin, BaseFilterSet):
     )
     type = django_filters.MultipleChoiceFilter(choices=CableTypeChoices)
     color = django_filters.MultipleChoiceFilter(choices=ColorChoices)
-    device_id = MultiValueCharFilter(method="filter_device")
-    device = MultiValueCharFilter(method="filter_device", field_name="device__name")
-    rack_id = MultiValueCharFilter(method="filter_device", field_name="device__rack_id")
-    rack = MultiValueCharFilter(method="filter_device", field_name="device__rack__name")
-    site_id = MultiValueCharFilter(method="filter_device", field_name="device__site_id")
-    site = MultiValueCharFilter(method="filter_device", field_name="device__site__slug")
-    tenant_id = MultiValueCharFilter(method="filter_device", field_name="device__tenant_id")
-    tenant = MultiValueCharFilter(method="filter_device", field_name="device__tenant__slug")
+    device_id = MultiValueCharFilter(method="filter_device", label="Device (ID)")
+    device = MultiValueCharFilter(method="filter_device", field_name="device__name", label="Device (name)")
+    rack_id = MultiValueCharFilter(method="filter_device", field_name="device__rack_id", label="Rack (ID)")
+    rack = MultiValueCharFilter(method="filter_device", field_name="device__rack__name", label="Rack (name)")
+    site_id = MultiValueCharFilter(method="filter_device", field_name="device__site_id", label="Site (ID)")
+    site = MultiValueCharFilter(method="filter_device", field_name="device__site__slug", label="Site (name)")
+    tenant_id = MultiValueCharFilter(method="filter_device", field_name="device__tenant_id", label="Tenant (ID)")
+    tenant = MultiValueCharFilter(method="filter_device", field_name="device__tenant__slug", label="Tenant (name)")
     tag = TagFilter()
 
     class Meta:
@@ -1181,8 +1181,8 @@ class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
         method="filter_site",
         label="Site (slug)",
     )
-    device_id = MultiValueNumberFilter(method="filter_device")
-    device = MultiValueCharFilter(method="filter_device", field_name="device__name")
+    device_id = MultiValueCharFilter(method="filter_device", label="Device (ID)")
+    device = MultiValueCharFilter(method="filter_device", field_name="device__name", label="Device (name)")
 
     class Meta:
         model = ConsolePort
@@ -1194,8 +1194,8 @@ class PowerConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
         method="filter_site",
         label="Site (slug)",
     )
-    device_id = MultiValueNumberFilter(method="filter_device")
-    device = MultiValueCharFilter(method="filter_device", field_name="device__name")
+    device_id = MultiValueCharFilter(method="filter_device", label="Device (ID)")
+    device = MultiValueCharFilter(method="filter_device", field_name="device__name", label="Device (name)")
 
     class Meta:
         model = PowerPort
@@ -1207,8 +1207,8 @@ class InterfaceConnectionFilterSet(ConnectionFilterSet, BaseFilterSet):
         method="filter_site",
         label="Site (slug)",
     )
-    device_id = MultiValueNumberFilter(method="filter_device")
-    device = MultiValueCharFilter(method="filter_device", field_name="device__name")
+    device_id = MultiValueCharFilter(method="filter_device", label="Device (ID)")
+    device = MultiValueCharFilter(method="filter_device", field_name="device__name", label="Device (name)")
 
     class Meta:
         model = Interface
diff --git a/nautobot/ipam/filters.py b/nautobot/ipam/filters.py
--- a/nautobot/ipam/filters.py
+++ b/nautobot/ipam/filters.py
@@ -465,7 +465,7 @@ class IPAddressFilterSet(
         field_name="name",
         label="Device (name)",
     )
-    device_id = MultiValueNumberFilter(
+    device_id = MultiValueCharFilter(
         method="filter_device",
         field_name="pk",
         label="Device (ID)",
@@ -475,7 +475,7 @@ class IPAddressFilterSet(
         field_name="name",
         label="Virtual machine (name)",
     )
-    virtual_machine_id = MultiValueNumberFilter(
+    virtual_machine_id = MultiValueCharFilter(
         method="filter_virtual_machine",
         field_name="pk",
         label="Virtual machine (ID)",


Test patch:
diff --git a/nautobot/core/tests/test_graphql.py b/nautobot/core/tests/test_graphql.py
--- a/nautobot/core/tests/test_graphql.py
+++ b/nautobot/core/tests/test_graphql.py
@@ -26,7 +26,7 @@
     extend_schema_type_config_context,
     extend_schema_type_relationships,
 )
-from nautobot.dcim.choices import InterfaceTypeChoices
+from nautobot.dcim.choices import InterfaceTypeChoices, InterfaceModeChoices
 from nautobot.dcim.filters import DeviceFilterSet, SiteFilterSet
 from nautobot.dcim.graphql.types import DeviceType as DeviceTypeGraphQL
 from nautobot.dcim.models import Cable, Device, DeviceRole, DeviceType, Interface, Manufacturer, Rack, Region, Site
@@ -512,6 +512,9 @@ def setUp(self):
         self.tenant1 = Tenant.objects.create(name="Tenant 1", slug="tenant-1")
         self.tenant2 = Tenant.objects.create(name="Tenant 2", slug="tenant-2")
 
+        self.vlan1 = VLAN.objects.create(name="VLAN 1", vid=100, site=self.site1)
+        self.vlan2 = VLAN.objects.create(name="VLAN 2", vid=200, site=self.site2)
+
         self.device1 = Device.objects.create(
             name="Device 1",
             device_type=self.devicetype,
@@ -525,10 +528,17 @@ def setUp(self):
         )
 
         self.interface11 = Interface.objects.create(
-            name="Int1", type=InterfaceTypeChoices.TYPE_VIRTUAL, device=self.device1, mac_address="00:11:11:11:11:11"
+            name="Int1",
+            type=InterfaceTypeChoices.TYPE_VIRTUAL,
+            device=self.device1,
+            mac_address="00:11:11:11:11:11",
+            mode=InterfaceModeChoices.MODE_ACCESS,
+            untagged_vlan=self.vlan1,
         )
         self.interface12 = Interface.objects.create(
-            name="Int2", type=InterfaceTypeChoices.TYPE_VIRTUAL, device=self.device1
+            name="Int2",
+            type=InterfaceTypeChoices.TYPE_VIRTUAL,
+            device=self.device1,
         )
         self.ipaddr1 = IPAddress.objects.create(
             address="10.0.1.1/24", status=self.status1, assigned_object=self.interface11
@@ -546,7 +556,11 @@ def setUp(self):
         )
 
         self.interface21 = Interface.objects.create(
-            name="Int1", type=InterfaceTypeChoices.TYPE_VIRTUAL, device=self.device2
+            name="Int1",
+            type=InterfaceTypeChoices.TYPE_VIRTUAL,
+            device=self.device2,
+            untagged_vlan=self.vlan2,
+            mode=InterfaceModeChoices.MODE_ACCESS,
         )
         self.interface22 = Interface.objects.create(
             name="Int2", type=InterfaceTypeChoices.TYPE_1GE_FIXED, device=self.device2, mac_address="00:12:12:12:12:12"
@@ -566,6 +580,13 @@ def setUp(self):
         self.interface31 = Interface.objects.create(
             name="Int1", type=InterfaceTypeChoices.TYPE_VIRTUAL, device=self.device3
         )
+        self.interface31 = Interface.objects.create(
+            name="Mgmt1",
+            type=InterfaceTypeChoices.TYPE_VIRTUAL,
+            device=self.device3,
+            mgmt_only=True,
+            enabled=False,
+        )
 
         self.cable1 = Cable.objects.create(
             termination_a=self.interface11,
@@ -772,3 +793,26 @@ def test_query_cables_filter(self):
                 result = self.execute_query(query)
                 self.assertIsNone(result.errors)
                 self.assertEqual(len(result.data["cables"]), nbr_expected_results)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
+    def test_query_interfaces_filter(self):
+        """Test custom interface filter fields and boolean, not other concrete fields."""
+
+        filters = (
+            (f'device_id: "{self.device1.id}"', 2),
+            ('device: "Device 3"', 2),
+            ('device: ["Device 1", "Device 3"]', 4),
+            ('kind: "virtual"', 5),
+            ('mac_address: "00:11:11:11:11:11"', 1),
+            ("vlan: 100", 1),
+            (f'vlan_id: "{self.vlan1.id}"', 1),
+            ("mgmt_only: true", 1),
+            ("enabled: false", 1),
+        )
+
+        for filter, nbr_expected_results in filters:
+            with self.subTest(msg=f"Checking {filter}", filter=filter, nbr_expected_results=nbr_expected_results):
+                query = "query { interfaces(" + filter + "){ id }}"
+                result = self.execute_query(query)
+                self.assertIsNone(result.errors)
+                self.assertEqual(len(result.data["interfaces"]), nbr_expected_results)
diff --git a/nautobot/dcim/tests/test_filters.py b/nautobot/dcim/tests/test_filters.py
--- a/nautobot/dcim/tests/test_filters.py
+++ b/nautobot/dcim/tests/test_filters.py
@@ -38,7 +38,7 @@
     VirtualChassis,
 )
 from nautobot.extras.models import Status
-from nautobot.ipam.models import IPAddress
+from nautobot.ipam.models import IPAddress, VLAN
 from nautobot.tenancy.models import Tenant, TenantGroup
 from nautobot.virtualization.models import Cluster, ClusterType
 
@@ -2299,6 +2299,10 @@ def setUpTestData(cls):
             ),  # For cable connections
         )
 
+        vlan1 = VLAN.objects.create(name="VLAN 1", vid=1)
+        vlan2 = VLAN.objects.create(name="VLAN 2", vid=2)
+        vlan3 = VLAN.objects.create(name="VLAN 3", vid=3)
+
         interfaces = (
             Interface.objects.create(
                 device=devices[0],
@@ -2309,6 +2313,7 @@ def setUpTestData(cls):
                 mtu=100,
                 mode=InterfaceModeChoices.MODE_ACCESS,
                 mac_address="00-00-00-00-00-01",
+                untagged_vlan=vlan1,
                 description="First",
             ),
             Interface.objects.create(
@@ -2320,6 +2325,7 @@ def setUpTestData(cls):
                 mtu=200,
                 mode=InterfaceModeChoices.MODE_TAGGED,
                 mac_address="00-00-00-00-00-02",
+                untagged_vlan=vlan2,
                 description="Second",
             ),
             Interface.objects.create(
@@ -2356,6 +2362,10 @@ def setUpTestData(cls):
             ),
         )
 
+        # Tagged VLAN interface is "Interface 6"
+        tagged_interface = interfaces[-1]
+        tagged_interface.tagged_vlans.add(vlan3)
+
         cable_statuses = Status.objects.get_for_model(Cable)
         status_connected = cable_statuses.get(slug="connected")
 
@@ -2459,6 +2469,14 @@ def test_type(self):
         }
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_vlan(self):
+        params = {"vlan": VLAN.objects.first().vid}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_vlan_id(self):
+        params = {"vlan_id": VLAN.objects.last().id}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
 
 class FrontPortTestCase(TestCase):
     queryset = FrontPort.objects.all()