Repository: netbox-community/netbox
Pull request: Handle `null_option` in `applied_filters` template tag
### Fixes: #7205

- Apply same logic from `DynamicModelChoiceField.clean()` to `DynamicModelMultipleChoiceField` field so that `None` is added to cleaned form data
- More importantly, use a field's `null_option` for `applied_filters` template tag when selected. For example, if 'Global' is selected, the filter should show 'Global'. After my fix from 2a293d5 in APISelect, `applied_filters` is now broken when a field with a `null_option` is selected.

Problem statement:
`applied_filters` template tag does not handle `null_option` on form fields
### NetBox version

v3.0.2-dev

### Python version

3.9

### Steps to Reproduce

Prior to 2a293d5, you won't be able to reproduce this because `null_option` is not properly handled in the UI. After updating...

1. Navigate to IPAM → IP Addresses → Filter
2. Select 'Global' VRF in 'Available VRFs' field
3. Click Search

### Expected Behavior

When `null_option` is present on a selected form field and is not `None`, the applied filters should display the `null_option` value.

### Observed Behavior

`KeyError` is raised because `vrf_id` is not in the cleaned form data.

Hints from issue discussion:

Commit message: Return cleaned `null_option` value as `None` in dynamic multi-select field

Patch to review:
diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md
index e1bb57d7277..f1b96d8b362 100644
--- a/docs/release-notes/version-3.0.md
+++ b/docs/release-notes/version-3.0.md
@@ -10,6 +10,7 @@
 * [#7176](https://github.com/netbox-community/netbox/issues/7176) - Fix issue where query parameters were duplicated across different forms of the same type
 * [#7188](https://github.com/netbox-community/netbox/issues/7188) - Fix issue where select fields with `null_option` did not render or send the null option
 * [#7193](https://github.com/netbox-community/netbox/issues/7193) - Fix prefix (flat) template issue when viewing child prefixes with prefixes available
+* [#7205](https://github.com/netbox-community/netbox/issues/7205) - Fix issue where selected fields with `null_option` set were not added to applied filters
 
 ---
 
diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py
index 05eaa9515ef..2561c2e22d7 100644
--- a/netbox/utilities/forms/fields.py
+++ b/netbox/utilities/forms/fields.py
@@ -477,3 +477,13 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
     """
     filter = django_filters.ModelMultipleChoiceFilter
     widget = widgets.APISelectMultiple
+
+    def clean(self, value):
+        """
+        When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
+        string 'null'.  This will check for that condition and gracefully handle the conversion to a NoneType.
+        """
+        if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value:
+            value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE]
+            return [None, *value]
+        return super().clean(value)
diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py
index 0121b250cfb..3297f7955e8 100644
--- a/netbox/utilities/forms/utils.py
+++ b/netbox/utilities/forms/utils.py
@@ -120,13 +120,21 @@ def get_selected_values(form, field_name):
     if not hasattr(form, 'cleaned_data'):
         form.is_valid()
     filter_data = form.cleaned_data.get(field_name)
-
+    field = form.fields[field_name]
     # Selection field
-    if hasattr(form.fields[field_name], 'choices'):
+    if hasattr(field, 'choices'):
         try:
-            choices = dict(unpack_grouped_choices(form.fields[field_name].choices))
+            grouped_choices = [(k, v) for k, v in field.choices]
+
+            if hasattr(field, 'null_option'):
+                # If the field has a `null_option` attribute set and it is selected,
+                # add it to the field's grouped choices.
+                if field.null_option is not None and None in filter_data:
+                    grouped_choices.append((field.null_option, field.null_option))
+
+            choices = dict(unpack_grouped_choices(grouped_choices))
             return [
-                label for value, label in choices.items() if str(value) in filter_data
+                label for value, label in choices.items() if str(value) in filter_data or None in filter_data
             ]
         except TypeError:
             # Field uses dynamic choices. Show all that have been populated.


Code review comments:
diff_hunk: @@ -120,13 +120,21 @@ def get_selected_values(form, field_name):
     if not hasattr(form, 'cleaned_data'):
         form.is_valid()
     filter_data = form.cleaned_data.get(field_name)
-
+    field = form.fields[field_name]
     # Selection field
-    if hasattr(form.fields[field_name], 'choices'):
+    if hasattr(field, 'choices'):
         try:
-            choices = dict(unpack_grouped_choices(form.fields[field_name].choices))
+            grouped_choices = [(k, v) for k, v in field.choices]
path: netbox/utilities/forms/utils.py
text: @user1:
This line can be omitted. We should call `unpack_grouped_choices()` first to flatten any grouped choices, then append the null choice as needed.

diff_hunk: @@ -120,13 +120,21 @@ def get_selected_values(form, field_name):
     if not hasattr(form, 'cleaned_data'):
         form.is_valid()
     filter_data = form.cleaned_data.get(field_name)
-
+    field = form.fields[field_name]
     # Selection field
-    if hasattr(form.fields[field_name], 'choices'):
+    if hasattr(field, 'choices'):
         try:
-            choices = dict(unpack_grouped_choices(form.fields[field_name].choices))
+            grouped_choices = [(k, v) for k, v in field.choices]
+
+            if hasattr(field, 'null_option'):
+                # If the field has a `null_option` attribute set and it is selected,
+                # add it to the field's grouped choices.
+                if field.null_option is not None and None in filter_data:
+                    grouped_choices.append((field.null_option, field.null_option))
path: netbox/utilities/forms/utils.py
text: @user1:
Even though it's not actually referenced, we should probably set the value for the null choice to `settings.FILTERS_NULL_CHOICE_VALUE` just for clarity.