Repository: nautobot/nautobot

Issue:
Simplify MultipleContentTypeField boilerplate
<!--
    NOTE: This template is for use by maintainers only. Please do not submit
    an issue using this template unless you have been specifically asked to
    do so.
-->
### Proposed Changes

Followup to #92 - Provide a custom `__init__` method for the MultipleContentTypeField class, so that we can more simply instantiate it, along the lines of:

```python
source_type = MultipleContentTypeField(feature="relationships", required=False, label="Source Type")
```

Possibly something similar should also be implemented for `CSVMultipleContentTypeField`?

<!-- Provide justification for the proposed change(s). -->
### Justification

Currently there's a lot of boilerplate in instantiating a MultipleContentTypeField, particularly in constructing and ordering the queryset correctly.

```python
    source_type = MultipleContentTypeField(
        queryset=ContentType.objects.filter(FeatureQuery("relationships").get_query()).order_by("app_label", "model"),
        required=False,
        label="Source Type",
        widget=StaticSelect2Multiple(),
    )

    destination_type = MultipleContentTypeField(
        queryset=ContentType.objects.filter(FeatureQuery("relationships").get_query()).order_by("app_label", "model"),
        required=False,
        label="Destination Type",
        widget=StaticSelect2Multiple(),
    )
```

It would be nice if we could instantiate it with a simple `feature="relationships"` and have the appropriate queryset get automatically constructed and ordered. It also probably makes sense for this field to default to using a `StaticSelect2Multiple` widget instead of requiring that to be manually specified each time.


Discussion:
@glennmatthews can you take this for 1.0.0 ? 


Resolving patch:
diff --git a/nautobot/extras/forms.py b/nautobot/extras/forms.py
--- a/nautobot/extras/forms.py
+++ b/nautobot/extras/forms.py
@@ -203,17 +203,11 @@ class RelationshipFilterForm(BootstrapMixin, forms.Form):
     type = forms.MultipleChoiceField(choices=RelationshipTypeChoices, required=False, widget=StaticSelect2Multiple())
 
     source_type = MultipleContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery("relationships").get_query()).order_by("app_label", "model"),
-        required=False,
-        label="Source Type",
-        widget=StaticSelect2Multiple(),
+        feature="relationships", choices_as_strings=True, required=False, label="Source Type"
     )
 
     destination_type = MultipleContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery("relationships").get_query()).order_by("app_label", "model"),
-        required=False,
-        label="Destination Type",
-        widget=StaticSelect2Multiple(),
+        feature="relationships", choices_as_strings=True, required=False, label="Destination Type"
     )
 
 
@@ -320,17 +314,11 @@ class RelationshipAssociationFilterForm(BootstrapMixin, forms.Form):
     )
 
     source_type = MultipleContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery("relationships").get_query()).order_by("app_label", "model"),
-        required=False,
-        label="Source Type",
-        widget=StaticSelect2Multiple(),
+        feature="relationships", choices_as_strings=True, required=False, label="Source Type"
     )
 
     destination_type = MultipleContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery("relationships").get_query()).order_by("app_label", "model"),
-        required=False,
-        label="Destination Type",
-        widget=StaticSelect2Multiple(),
+        feature="relationships", choices_as_strings=True, required=False, label="Destination Type"
     )
 
 
@@ -720,11 +708,7 @@ class CustomLinkFilterForm(BootstrapMixin, forms.Form):
 
 
 class WebhookForm(BootstrapMixin, forms.ModelForm):
-    content_types = forms.ModelMultipleChoiceField(
-        queryset=ContentType.objects.filter(FeatureQuery("webhooks").get_query()).order_by("app_label", "model"),
-        required=False,
-        label="Content Types",
-    )
+    content_types = MultipleContentTypeField(feature="webhooks", required=False, label="Content Type(s)")
 
     class Meta:
         model = Webhook
@@ -749,10 +733,8 @@ class Meta:
 class WebhookFilterForm(BootstrapMixin, forms.Form):
     model = Webhook
     q = forms.CharField(required=False, label="Search")
-    content_types = forms.ModelMultipleChoiceField(
-        queryset=ContentType.objects.filter(FeatureQuery("webhooks").get_query()).order_by("app_label", "model"),
-        required=False,
-        label="Content Types",
+    content_types = MultipleContentTypeField(
+        feature="webhooks", choices_as_strings=True, required=False, label="Content Type(s)"
     )
     type_create = forms.NullBooleanField(required=False, widget=StaticSelect2(choices=BOOLEAN_WITH_BLANK_CHOICES))
     type_update = forms.NullBooleanField(required=False, widget=StaticSelect2(choices=BOOLEAN_WITH_BLANK_CHOICES))
@@ -769,10 +751,7 @@ class StatusForm(BootstrapMixin, CustomFieldModelForm, RelationshipModelForm):
     """Generic create/update form for `Status` objects."""
 
     slug = SlugField()
-    content_types = forms.ModelMultipleChoiceField(
-        queryset=ContentType.objects.filter(FeatureQuery("statuses").get_query()).order_by("app_label", "model"),
-        label="Content type(s)",
-    )
+    content_types = MultipleContentTypeField(feature="statuses", label="Content Type(s)")
 
     class Meta:
         model = Status
@@ -785,7 +764,8 @@ class StatusCSVForm(CustomFieldModelCSVForm):
 
     slug = SlugField()
     content_types = CSVMultipleContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery("statuses").get_query()).order_by("app_label", "model"),
+        feature="statuses",
+        choices_as_strings=True,
         help_text=mark_safe(
             "The object types to which this status applies. Multiple values "
             "must be comma-separated and wrapped in double quotes. (e.g. "
@@ -808,10 +788,7 @@ class StatusFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Status
     q = forms.CharField(required=False, label="Search")
     content_types = MultipleContentTypeField(
-        queryset=ContentType.objects.filter(FeatureQuery("statuses").get_query()).order_by("app_label", "model"),
-        required=False,
-        label="Content type(s)",
-        widget=StaticSelect2Multiple(),
+        feature="statuses", choices_as_strings=True, required=False, label="Content Type(s)"
     )
     color = forms.CharField(max_length=6, required=False, widget=ColorSelect())
 
@@ -821,11 +798,7 @@ class StatusBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
     pk = forms.ModelMultipleChoiceField(queryset=Status.objects.all(), widget=forms.MultipleHiddenInput)
     color = forms.CharField(max_length=6, required=False, widget=ColorSelect())
-    content_types = forms.ModelMultipleChoiceField(
-        queryset=ContentType.objects.filter(FeatureQuery("statuses").get_query()).order_by("app_label", "model"),
-        label="Content type(s)",
-        required=False,
-    )
+    content_types = MultipleContentTypeField(feature="statuses", required=False, label="Content Type(s)")
 
     class Meta:
         nullable_fields = []
diff --git a/nautobot/utilities/forms/fields.py b/nautobot/utilities/forms/fields.py
--- a/nautobot/utilities/forms/fields.py
+++ b/nautobot/utilities/forms/fields.py
@@ -6,12 +6,14 @@
 import django_filters
 from django import forms
 from django.apps import apps
+from django.contrib.contenttypes.models import ContentType
 from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
 from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
 from django.db.models import Count
 from django.forms import BoundField
 from django.urls import reverse
 
+from nautobot.extras.utils import FeatureQuery
 from nautobot.utilities.choices import unpack_grouped_choices
 from nautobot.utilities.validators import EnhancedURLValidator
 from . import widgets
@@ -166,19 +168,39 @@ def to_python(self, value):
 
 class MultipleContentTypeField(forms.ModelMultipleChoiceField):
     """
-    Reference a list of `ContentType` objects in the form `{app_label}.{model}'.
+    Field for choosing any number of `ContentType` objects.
+
+    Optionally can restrict the available ContentTypes to those supporting a particular feature only.
+    Optionally can pass the selection through as a list of "`{app_label}.{model}`" strings intead of PK values.
     """
 
     STATIC_CHOICES = True
 
-    def __init__(self, *args, **kwargs):
+    def __init__(self, *args, feature=None, choices_as_strings=False, **kwargs):
+        """
+        Construct a MultipleContentTypeField.
+
+        Args:
+            feature (str): Feature name to use in constructing a FeatureQuery to restrict the available ContentTypes.
+            choices_as_strings (bool): If True, render selection as a list of `"{app_label}.{model}"` strings.
+        """
+        if "queryset" not in kwargs:
+            if feature is not None:
+                kwargs["queryset"] = ContentType.objects.filter(FeatureQuery(feature).get_query()).order_by(
+                    "app_label", "model"
+                )
+            else:
+                kwargs["queryset"] = ContentType.objects.order_by("app_label", "model")
+        if "widget" not in kwargs:
+            kwargs["widget"] = widgets.StaticSelect2Multiple()
+
         super().__init__(*args, **kwargs)
 
-        # Generate choices from queryset each time form is initialized.
-        self.choices = self._generate_choices_from_queryset
+        if choices_as_strings:
+            self.choices = self._string_choices_from_queryset
 
-    def _generate_choices_from_queryset(self):
-        """Overload choices to return "<app>.<model>" for CSV import help text."""
+    def _string_choices_from_queryset(self):
+        """Overload choices to return "<app>.<model>" instead of PKs."""
         return [(f"{m.app_label}.{m.model}", m.app_labeled_name) for m in self.queryset.all()]
 
 


Test patch: