Repository: nautobot/nautobot

Issue:
Duplicated entries in the Roles dropdown when creating a new Config Context
<!--
    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.1.7 https://demo.nautobot.com/
* Python version:
* Database platform, version:
* 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 new Config Context
2. In the Assignment panel, click the Roles field
3. Observe duplicated role entries (distribution, edge, Backbone etc. all appear twice).

<!-- What did you expect to happen? -->
### Expected Behavior

Each role should only appear once.

<!-- What happened instead? -->
### Observed Behavior

![image](https://github.com/nautobot/nautobot/assets/9288571/9d6230f3-29e8-477d-b086-b94b35e5d7fa)


### Investigation

This API call sent on click is the one returning the duplicated objects: https://demo.nautobot.com/api/extras/roles/?q=&limit=50&offset=0&depth=0&content_types=dcim.device&content_types=virtualization.virtualmachine

It looks like the duplication comes from `content_types=dcim.device&content_types=virtualization.virtualmachine`, if you remove one of them then roles appear only once in the result.



Discussion:

Resolving patch:
diff --git a/nautobot/core/filters.py b/nautobot/core/filters.py
--- a/nautobot/core/filters.py
+++ b/nautobot/core/filters.py
@@ -260,6 +260,9 @@ def filter(self, qs, value):
         if not self.conjoined:
             qs = qs.filter(q)
 
+        if self.distinct:
+            qs = qs.distinct()
+
         return qs
 
 
@@ -522,7 +525,10 @@ def filter(self, qs, value):
 
         # Fetch the generated Q object and filter the incoming qs with it before passing it along.
         query = self.generate_query(value)
-        return self.get_method(qs)(query)
+        result = self.get_method(qs)(query)
+        if self.distinct:
+            result = result.distinct()
+        return result
 
 
 #


Test patch:
diff --git a/nautobot/circuits/tests/test_filters.py b/nautobot/circuits/tests/test_filters.py
--- a/nautobot/circuits/tests/test_filters.py
+++ b/nautobot/circuits/tests/test_filters.py
@@ -57,7 +57,7 @@ def setUpTestData(cls):
     def test_location(self):
         expected = self.queryset.filter(
             circuits__circuit_terminations__location__in=[self.locations[0].pk, self.locations[1].pk]
-        )
+        ).distinct()
         params = {"location": [self.locations[0].pk, self.locations[1].pk]}
         self.assertQuerysetEqualAndNotEmpty(self.filterset(params, self.queryset).qs, expected)
         params = {"location": [self.locations[0].name, self.locations[1].name]}
diff --git a/nautobot/core/tests/test_api.py b/nautobot/core/tests/test_api.py
--- a/nautobot/core/tests/test_api.py
+++ b/nautobot/core/tests/test_api.py
@@ -773,21 +773,23 @@ def setUpTestData(cls):
             "TextField": "admin_contact",
             "DateTimeField": "created",
         }
+        cls.maxDiff = None
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
     def test_ascending_sort(self):
         """Tests that results are returned in the expected ascending order."""
 
         for field_type, field_name in self.field_type_map.items():
-            with self.subTest(f"Testing {field_type}"):
-                response = self.client.get(f"{self.url}?sort={field_name}&limit=10", **self.header)
+            with self.subTest(f"Testing {field_type} {field_name}"):
+                # Use `name` as a secondary sort as fields like `asn` and `admin_contact` may be null
+                response = self.client.get(f"{self.url}?sort={field_name},name&limit=10", **self.header)
                 self.assertHttpStatus(response, 200)
                 self.assertEqual(
                     list(map(lambda p: p["id"], response.data["results"])),
                     list(
                         map(
-                            lambda p: str(p),  # pylint: disable=unnecessary-lambda
-                            Provider.objects.order_by(field_name).values_list("id", flat=True)[:10],
+                            str,
+                            Provider.objects.order_by(field_name, "name").values_list("id", flat=True)[:10],
                         )
                     ),
                 )
@@ -797,15 +799,16 @@ def test_descending_sort(self):
         """Tests that results are returned in the expected descending order."""
 
         for field_type, field_name in self.field_type_map.items():
-            with self.subTest(f"Testing {field_type}"):
-                response = self.client.get(f"{self.url}?sort=-{field_name}&limit=10", **self.header)
+            with self.subTest(f"Testing {field_type} {field_name}"):
+                # Use `name` as a secondary sort as fields like `asn` and `admin_contact` may be null
+                response = self.client.get(f"{self.url}?sort=-{field_name},name&limit=10", **self.header)
                 self.assertHttpStatus(response, 200)
                 self.assertEqual(
                     list(map(lambda p: p["id"], response.data["results"])),
                     list(
                         map(
-                            lambda p: str(p),  # pylint: disable=unnecessary-lambda
-                            Provider.objects.order_by(f"-{field_name}").values_list("id", flat=True)[:10],
+                            str,
+                            Provider.objects.order_by(f"-{field_name}", "name").values_list("id", flat=True)[:10],
                         )
                     ),
                 )
diff --git a/nautobot/extras/tests/test_filters.py b/nautobot/extras/tests/test_filters.py
--- a/nautobot/extras/tests/test_filters.py
+++ b/nautobot/extras/tests/test_filters.py
@@ -1811,8 +1811,8 @@ class RoleTestCase(FilterTestCases.NameOnlyFilterTestCase):
     def test_content_types(self):
         device_ct = ContentType.objects.get_for_model(Device)
         rack_ct = ContentType.objects.get_for_model(Rack)
-        device_roles = self.queryset.filter(content_types=device_ct)
-        params = {"content_types": ["dcim.device"]}
+        device_roles = self.queryset.filter(content_types__in=[device_ct, rack_ct]).distinct()
+        params = {"content_types": ["dcim.device", "dcim.rack"]}
         self.assertQuerysetEqualAndNotEmpty(self.filterset(params, self.queryset).qs, device_roles)
 
         rack_roles = self.queryset.filter(content_types=rack_ct)