Repository: nautobot/nautobot

Issue:
`ContactAssociationFilterSet.associated_object_type` should use `ContentTypeFilter`
### Environment
* Nautobot version (Docker tag too if applicable): 2.2.0

### Steps to Reproduce

https://demo.nautobot.com/api/extras/contact-associations/?associated_object_type=dcim.device

Returns:

```json
{
    "associated_object_type": [
        "Select a valid choice. That choice is not one of the available choices."
    ]
}
```

Compare to queries like:

https://demo.nautobot.com/api/extras/custom-fields/?content_types=dcim.location

which do work as expected.

<!-- What did you expect to happen? -->
### Expected Behavior

`associated_object_type` filter should behave like other ContentTypeFilter in Nautobot, allowing user to specify the content-type by app-label + model name, rather than requiring a PK.

<!-- What happened instead? -->
### Observed Behavior

Filter requires content-type PK:

https://demo.nautobot.com/api/extras/contact-associations/?associated_object_type=4


Discussion:
Also add a generic test to look for this and open an issue to have this added as a rule in pylint-nautobot

Resolving patch:
diff --git a/nautobot/core/filters.py b/nautobot/core/filters.py
--- a/nautobot/core/filters.py
+++ b/nautobot/core/filters.py
@@ -176,6 +176,9 @@ def filter(self, qs, value):
         if value in EMPTY_VALUES:
             return qs
 
+        if value.isdigit():
+            return qs.filter(**{f"{self.field_name}__pk": value})
+
         try:
             app_label, model = value.lower().split(".")
         except ValueError:
@@ -242,6 +245,9 @@ def filter(self, qs, value):
             if self.conjoined:
                 qs = ContentTypeFilter.filter(self, qs, v)
             else:
+                if v.isdigit():
+                    q |= models.Q(**{f"{self.field_name}__pk": value})
+                    continue
                 # Similar to the ContentTypeFilter.filter() call above, but instead of narrowing the query each time
                 # (a AND b AND c ...) we broaden the query each time (a OR b OR c ...).
                 # Specifically, we're mapping a value like ['dcim.device', 'ipam.vlan'] to a query like
diff --git a/nautobot/extras/filters/__init__.py b/nautobot/extras/filters/__init__.py
--- a/nautobot/extras/filters/__init__.py
+++ b/nautobot/extras/filters/__init__.py
@@ -504,7 +504,7 @@ class Meta:
         fields = "__all__"
 
 
-class ContactAssociationFilterSet(NautobotFilterSet):
+class ContactAssociationFilterSet(NautobotFilterSet, StatusModelFilterSetMixin, RoleModelFilterSetMixin):
     q = SearchFilter(
         filter_predicates={
             "contact__name": "icontains",
@@ -512,6 +512,19 @@ class ContactAssociationFilterSet(NautobotFilterSet):
         },
     )
 
+    contact = NaturalKeyOrPKMultipleChoiceFilter(
+        queryset=Contact.objects.all(),
+        to_field_name="name",
+        label="Contact (name or ID)",
+    )
+    team = NaturalKeyOrPKMultipleChoiceFilter(
+        queryset=Team.objects.all(),
+        to_field_name="name",
+        label="Team (name or ID)",
+    )
+
+    associated_object_type = ContentTypeFilter()
+
     class Meta:
         model = ContactAssociation
         fields = "__all__"
@@ -610,6 +623,7 @@ class ExportTemplateFilterSet(BaseFilterSet):
         },
     )
     owner_content_type = ContentTypeFilter()
+    content_type = ContentTypeFilter()
 
     class Meta:
         model = ExportTemplate


Test patch:
diff --git a/nautobot/core/testing/filters.py b/nautobot/core/testing/filters.py
--- a/nautobot/core/testing/filters.py
+++ b/nautobot/core/testing/filters.py
@@ -1,12 +1,19 @@
 import random
 import string
 
+from django.contrib.contenttypes.models import ContentType
 from django.db.models import Count, Q
 from django.db.models.fields.related import ManyToManyField
 from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
 from django.test import tag
 
-from nautobot.core.filters import RelatedMembershipBooleanFilter, SearchFilter
+from nautobot.core.filters import (
+    ContentTypeChoiceFilter,
+    ContentTypeFilter,
+    ContentTypeMultipleChoiceFilter,
+    RelatedMembershipBooleanFilter,
+    SearchFilter,
+)
 from nautobot.core.models.generics import PrimaryModel
 from nautobot.core.testing import views
 from nautobot.tenancy import models
@@ -286,6 +293,22 @@ def test_q_filter_valid(self):
                         obj, obj_field_name = self._get_nested_related_obj_and_its_field_name(obj, obj_field_name)
                     self._assert_q_filter_predicate_validity(obj, obj_field_name, filter_field_name, lookup_method)
 
+        def test_content_type_related_fields_uses_content_type_filter(self):
+            for field in self.queryset.model._meta.fields:
+                related_model = getattr(field, "related_model", None)
+                if not related_model or related_model != ContentType:
+                    continue
+                with self.subTest(
+                    f"Assert {self.filterset.__class__.__name__}.{field.name} implements ContentTypeFilter"
+                ):
+                    filter_field = self.filterset.get_filters().get(field.name)
+                    if not filter_field:
+                        # This field is not part of the Filterset.
+                        continue
+                    self.assertIsInstance(
+                        filter_field, (ContentTypeFilter, ContentTypeMultipleChoiceFilter, ContentTypeChoiceFilter)
+                    )
+
     class NameOnlyFilterTestCase(FilterTestCase):
         """Add simple tests for filtering by name."""
 
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
@@ -30,6 +30,7 @@
 from nautobot.extras.filters import (
     ComputedFieldFilterSet,
     ConfigContextFilterSet,
+    ContactAssociationFilterSet,
     ContactFilterSet,
     ContentTypeFilterSet,
     CustomFieldChoiceFilterSet,
@@ -555,6 +556,74 @@ class ContactFilterSetTestCase(ContactAndTeamFilterSetTestCaseMixin, FilterTestC
     )
 
 
+class ContactAssociationFilterSetTestCase(FilterTestCases.FilterTestCase):
+    queryset = ContactAssociation.objects.all()
+    filterset = ContactAssociationFilterSet
+
+    generic_filter_tests = (
+        ["status", "status__id"],
+        ["status", "status__name"],
+        ["contact", "contact__id"],
+        ["contact", "contact__name"],
+        ["team", "team__id"],
+        ["team", "team__name"],
+        ["role", "role__id"],
+        ["role", "role__name"],
+    )
+
+    @classmethod
+    def setUpTestData(cls):
+        roles = Role.objects.get_for_model(ContactAssociation)
+        statuses = Status.objects.get_for_model(ContactAssociation)
+        ip_addresses = IPAddress.objects.all()
+        locations = Location.objects.all()
+
+        cls.location_ct = ContentType.objects.get_for_model(Location)
+        ipaddress_ct = ContentType.objects.get_for_model(IPAddress)
+
+        ContactAssociation.objects.create(
+            contact=Contact.objects.first(),
+            associated_object_type=ipaddress_ct,
+            associated_object_id=ip_addresses[0].pk,
+            role=roles[2],
+            status=statuses[1],
+        )
+        ContactAssociation.objects.create(
+            contact=Contact.objects.last(),
+            associated_object_type=ipaddress_ct,
+            associated_object_id=ip_addresses[1].pk,
+            role=roles[1],
+            status=statuses[2],
+        )
+        ContactAssociation.objects.create(
+            team=Team.objects.first(),
+            associated_object_type=cls.location_ct,
+            associated_object_id=locations[0].pk,
+            role=roles[3],
+            status=statuses[0],
+        )
+        ContactAssociation.objects.create(
+            team=Team.objects.last(),
+            associated_object_type=cls.location_ct,
+            associated_object_id=locations[1].pk,
+            role=roles[0],
+            status=statuses[1],
+        )
+
+    def test_associated_object_type(self):
+        params = {"associated_object_type": "dcim.location"}
+        self.assertEqual(
+            self.filterset(params, self.queryset).qs.count(),
+            ContactAssociation.objects.filter(associated_object_type=self.location_ct).count(),
+        )
+
+        params = {"associated_object_type": self.location_ct.pk}
+        self.assertEqual(
+            self.filterset(params, self.queryset).qs.count(),
+            ContactAssociation.objects.filter(associated_object_type=self.location_ct).count(),
+        )
+
+
 class CustomFieldChoiceFilterSetTestCase(FilterTestCases.FilterTestCase):
     queryset = CustomFieldChoice.objects.all()
     filterset = CustomFieldChoiceFilterSet