Repository: nautobot/nautobot

Issue:
Custom Field filter doesn't work if UUID is provided
<!--
    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
* Nautobot version (Docker tag too if applicable): 2.0.6-py3.11 & 2.1.0-py3.11
* Python version: 3.11.7
* Database platform, version: Postgres 15.2
* Middleware(s):

<!--
    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.
-->
### Steps to Reproduce
1. Create a Custom Field named "Upgrade Group" of type Selection for Content Type dcim.device
2. Create multiple Custom Field Choices (Group A, Group B)
3. Edit a device and set the Upgrade Group custom field to Group A
4. Navigate to https://\<host\>/dcim/devices/?cf_upgrade_group=<uuid_of_group_a>

<!-- What did you expect to happen? -->
### Expected Behavior
Filtered device list displays (only devices with Upgrade Group set to "Group A")

<!-- What happened instead? -->
### Observed Behavior
Zero devices display

### Additional Information
Using the name instead of the uuid (https://\<host\>/dcim/devices/?cf_upgrade_group=Group%20A) works, but when the filter is used in a job like this:
```python
    upgrade_group = ObjectVar(
        label="Upgrade Group",
        description="Device custom field",
        model=CustomFieldChoice,
        required=False,
        query_params = {
            "custom_field": "upgrade_group",
        }
    )

    devices = ObjectVar(
        label="Devices",
        description="Optional Device filter",
        model=Device,
        required=False,
        query_params = {
            "device_type": "$device_type",
            "location": "$location",
            "cf_upgrade_group": "$upgrade_group",
        }
    )
```

The UUID not the name gets passed to the API. I see this in the compose logs:
```nginx          | 172.21.0.229 - - [21/Dec/2023:09:06:37 +0000] "GET /api/dcim/devices/?q=&limit=50&offset=0&depth=0&cf_upgrade_group=c639def9-2784-4f32-9637-3f4aa3cfbcaa HTTP/2.0" 200 52 "https://192.168.253.20/extras/jobs/e6cdbd6b-977a-42a1-9d17-13c9dd7fbbf3/run/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0" "-"```
and the device list in the job form is empty (no results found)


Discussion:
Thanks for the report. It looks like we'll need to change the default filters in `nautobot.extras.filters.CustomFieldModelFilterSetMixin` to use `NaturalKeyOrPKMultipleChoiceFilter` for  `CustomFieldTypeChoices.TYPE_MULTISELECT` and `CustomFieldTypeChoices.TYPE_SELECT`
Hi there, I'd be glad to have this implemented as well. We also noticed the inability to search custom fields by UUID but it would be very useful for us! e.g. `/ipam/prefixes/?cf_my_field=c1442670-c8bf-4604-ac91-86436baf661d` instead of just representing string value.
Needed in 1.6 :)

Resolving patch:
diff --git a/nautobot/extras/filters/customfields.py b/nautobot/extras/filters/customfields.py
--- a/nautobot/extras/filters/customfields.py
+++ b/nautobot/extras/filters/customfields.py
@@ -8,8 +8,9 @@
     MultiValueNumberFilter,
 )
 from nautobot.core.forms import NullableDateField
-from nautobot.core.forms.widgets import StaticSelect2Multiple
+from nautobot.core.utils.data import is_uuid
 from nautobot.extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices
+from nautobot.extras.models import CustomFieldChoice
 
 EXACT_FILTER_TYPES = (
     CustomFieldTypeChoices.TYPE_BOOLEAN,
@@ -71,19 +72,23 @@ class CustomFieldJSONFilter(CustomFieldFilterMixin, django_filters.Filter):
     """Custom field single value filter for backwards compatibility"""
 
 
-class CustomFieldMultiSelectFilter(CustomFieldFilterMixin, MultiValueCharFilter):
-    """This provides functionality for filtering custom fields with multiple  select type"""
+class CustomFieldSelectFilter(CustomFieldFilterMixin, MultiValueCharFilter):
+    """Filter for custom fields of type TYPE_SELECT."""
 
-    def __init__(self, *args, **kwargs):
-        kwargs.setdefault("lookup_expr", "contains")
-        super().__init__(*args, **kwargs)
+    def get_filter_predicate(self, v):
+        if is_uuid(v):
+            try:
+                v = self.custom_field.custom_field_choices.get(pk=v).value
+            except CustomFieldChoice.DoesNotExist:
+                v = ""
+        return super().get_filter_predicate(v)
 
 
-class CustomFieldMultiValueSelectFilter(CustomFieldFilterMixin, django_filters.MultipleChoiceFilter):
-    """This provides functionality for filtering custom fields with select type"""
+class CustomFieldMultiSelectFilter(CustomFieldSelectFilter):
+    """Filter for custom fields of type TYPE_MULTISELECT."""
 
     def __init__(self, *args, **kwargs):
-        self.field_class.widget = StaticSelect2Multiple
+        kwargs.setdefault("lookup_expr", "contains")
         super().__init__(*args, **kwargs)
 
 
diff --git a/nautobot/extras/filters/mixins.py b/nautobot/extras/filters/mixins.py
--- a/nautobot/extras/filters/mixins.py
+++ b/nautobot/extras/filters/mixins.py
@@ -27,6 +27,7 @@
     CustomFieldMultiValueDateFilter,
     CustomFieldMultiValueNumberFilter,
     CustomFieldNumberFilter,
+    CustomFieldSelectFilter,
 )
 from nautobot.extras.models import (
     ConfigContextSchema,
@@ -61,12 +62,16 @@ def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
         custom_field_filter_classes = {
+            # Here, for the "base" filters for each custom field, for backwards compatibility, use single-value filters.
+            # For the "extended" filters, see below, we use multi-value filters.
+            # 3.0 TODO: switch the "base" filters to multi-value filters as well.
             CustomFieldTypeChoices.TYPE_DATE: CustomFieldDateFilter,
             CustomFieldTypeChoices.TYPE_BOOLEAN: CustomFieldBooleanFilter,
             CustomFieldTypeChoices.TYPE_INTEGER: CustomFieldNumberFilter,
             CustomFieldTypeChoices.TYPE_JSON: CustomFieldJSONFilter,
+            # The below are multi-value filters already:
             CustomFieldTypeChoices.TYPE_MULTISELECT: CustomFieldMultiSelectFilter,
-            CustomFieldTypeChoices.TYPE_SELECT: CustomFieldMultiSelectFilter,
+            CustomFieldTypeChoices.TYPE_SELECT: CustomFieldSelectFilter,
         }
 
         custom_fields = CustomField.objects.get_for_model(self._meta.model, exclude_filter_disabled=True)


Test patch:
diff --git a/nautobot/extras/tests/test_customfields.py b/nautobot/extras/tests/test_customfields.py
--- a/nautobot/extras/tests/test_customfields.py
+++ b/nautobot/extras/tests/test_customfields.py
@@ -1537,8 +1537,10 @@ def setUpTestData(cls):
         cf.save()
         cf.content_types.set([obj_type])
 
-        CustomFieldChoice.objects.create(custom_field=cf, value="Foo")
-        CustomFieldChoice.objects.create(custom_field=cf, value="Bar")
+        cls.select_choices = (
+            CustomFieldChoice.objects.create(custom_field=cf, value="Foo"),
+            CustomFieldChoice.objects.create(custom_field=cf, value="Bar"),
+        )
 
         # Multi-select filtering
         cf = CustomField(
@@ -1548,8 +1550,11 @@ def setUpTestData(cls):
         cf.save()
         cf.content_types.set([obj_type])
 
-        CustomFieldChoice.objects.create(custom_field=cf, value="Foo")
-        CustomFieldChoice.objects.create(custom_field=cf, value="Bar")
+        cls.multiselect_choices = (
+            CustomFieldChoice.objects.create(custom_field=cf, value="Foo"),
+            CustomFieldChoice.objects.create(custom_field=cf, value="Bar"),
+        )
+
         cls.location_type = LocationType.objects.get(name="Campus")
         location_status = Status.objects.get_for_model(Location).first()
         Location.objects.create(
@@ -1844,6 +1849,10 @@ def test_filter_select(self):
             self.filterset({"cf_cf8": ["Foo", "AR"]}, self.queryset).qs,
             self.queryset.filter(_custom_field_data__cf8__in=["Foo", "AR"]),
         )
+        self.assertQuerysetEqualAndNotEmpty(  # https://github.com/nautobot/nautobot/issues/5009
+            self.filterset({"cf_cf8": [str(choice.pk) for choice in self.select_choices]}, self.queryset).qs,
+            self.queryset.filter(_custom_field_data__cf8__in=[choice.value for choice in self.select_choices]),
+        )
         self.assertQuerysetEqual(
             self.filterset({"cf_cf8__n": ["Foo"]}, self.queryset).qs,
             self.queryset.exclude(_custom_field_data__cf8="Foo")
@@ -1913,6 +1922,10 @@ def test_filter_multi_select(self):
             self.filterset({"cf_cf9": "Bar"}, self.queryset).qs,
             self.queryset.filter(_custom_field_data__cf9__contains="Bar"),
         )
+        self.assertQuerysetEqualAndNotEmpty(  # https://github.com/nautobot/nautobot/issues/5009
+            self.filterset({"cf_cf9": str(self.multiselect_choices[0].pk)}, self.queryset).qs,
+            self.queryset.filter(_custom_field_data__cf9__contains=self.multiselect_choices[0].value),
+        )
 
 
 class CustomFieldChoiceTest(ModelTestCases.BaseModelTestCase):