Repository: nautobot/nautobot

Issue:
Error when navigating Tenant Groups via Tenant breadcrumbs
<!--
    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.4
* Python version: n/a
* Database platform, version: n/a
* Middleware(s): n/a

<!--
    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. Log into demo.nautobot.com
2. Go to a Tenant that is assigned to a Tenant Group (ie: [ABC LLC](https://demo.nautobot.com/tenancy/tenants/65ee259e-8769-461e-a27d-5a4adcd121ef/?tab=main))
3. In the breadcrumbs, click on the Tenant Group (ABC Holding Corp in this example)

<!-- What did you expect to happen? -->
### Expected Behavior
I expect to be taken to the page for the selected Tenant Group.

<!-- What happened instead? -->
### Observed Behavior
Instead I am taken to a blank Tenants page, with an error banner displayed, saying:

```
Invalid filters were specified:
group
Unknown filter field
```

![Screenshot 2024-02-20 at 4 31 01 PM](https://github.com/nautobot/nautobot/assets/536467/2525c4ca-fad7-4913-8eff-fc8e0ee6588b)
![Screenshot 2024-02-20 at 4 31 10 PM](https://github.com/nautobot/nautobot/assets/536467/80e946df-dbae-4da6-afba-af8967b93f45)




Discussion:
Hyperlink needs to be updated to use `tenant_group` instead of `group` in 2.0.
Same problem when filtering vlans in vlan groups. Here the `group` must be updated to `vlan_group`

http://localhost:9317/ipam/vlans/?group=VG-ATGW
@jifox can you specify where you're finding that incorrect link? Is it in a VLAN breadcrumb, or somewhere else?
@glennmatthews 

I just open the VLANs and add a Filter for VLAN Group

![image](https://github.com/nautobot/nautobot/assets/8406301/6ff06a25-1b37-408c-9521-9f72b04f1ac9)

Thanks! Looks like that's a bug in `VLANFilterForm`. Looks like an issue in `PrefixForm.vlan` as well.
We need to go through the code for anywhere in a filter, form, or link that has a `group` not prefixed with something and see if it needs fixing - at a glance, the below all look suspect:

```
nautobot/dcim/forms.py:        query_params={"group_id": "$cluster_group"},
nautobot/ipam/forms.py:            "group_id": "$vlan_group",
nautobot/ipam/forms.py:    group_id = DynamicModelMultipleChoiceField(
nautobot/ipam/templates/ipam/inc/vlangroup_header.html:        <a href="{% url 'ipam:vlan_add' %}?vid={{ first_available_vlan }}&group={{ object.pk }}{% if object.location %}&location={{ object.location.pk }}{% endif %}" class="btn btn-success">
nautobot/ipam/templates/ipam/vlan.html:        <li><a href="{% url 'ipam:vlan_list' %}?group={{ object.vlan_group.name }}">{{ object.vlan_group }}</a></li>
nautobot/tenancy/templates/tenancy/tenant.html:                    <li><a href="{% url 'tenancy:tenant_list' %}?group={{ object.tenant_group.name }}">{{ object.tenant_group }}</a></li>
nautobot/tenancy/templates/tenancy/tenantgroup.html:                    <a href="{% url 'tenancy:tenant_add' %}?group={{ object.pk }}" class="btn btn-xs btn-primary">
nautobot/virtualization/templates/virtualization/clustergroup.html:                        <a href="{% url 'virtualization:cluster_list' %}?group={{ object.name }}">{{ cluster_table.rows|length }}</a>
nautobot/virtualization/templates/virtualization/clustergroup.html:                    <a href="{% url 'virtualization:cluster_add' %}?group={{ object.pk }}" class="btn btn-xs btn-primary">
```

Resolving patch:
diff --git a/nautobot/dcim/forms.py b/nautobot/dcim/forms.py
--- a/nautobot/dcim/forms.py
+++ b/nautobot/dcim/forms.py
@@ -1504,7 +1504,7 @@ class DeviceForm(LocatableModelFormMixin, NautobotModelForm, TenancyForm, LocalC
     cluster = DynamicModelChoiceField(
         queryset=Cluster.objects.all(),
         required=False,
-        query_params={"group_id": "$cluster_group"},
+        query_params={"cluster_group": "$cluster_group"},
     )
     vrfs = DynamicModelMultipleChoiceField(
         queryset=VRF.objects.all(),
diff --git a/nautobot/ipam/forms.py b/nautobot/ipam/forms.py
--- a/nautobot/ipam/forms.py
+++ b/nautobot/ipam/forms.py
@@ -245,7 +245,7 @@ class PrefixForm(LocatableModelFormMixin, NautobotModelForm, TenancyForm, Prefix
         required=False,
         label="VLAN group",
         null_option="None",
-        query_params={"location_id": "$location"},
+        query_params={"location": "$location"},
         initial_params={"vlans": "$vlan"},
     )
     vlan = DynamicModelChoiceField(
@@ -254,7 +254,7 @@ class PrefixForm(LocatableModelFormMixin, NautobotModelForm, TenancyForm, Prefix
         label="VLAN",
         query_params={
             "location": "$location",
-            "group_id": "$vlan_group",
+            "vlan_group": "$vlan_group",
         },
     )
     rir = DynamicModelChoiceField(queryset=RIR.objects.all(), required=False, label="RIR")
@@ -750,14 +750,14 @@ class VLANFilterForm(
     field_order = [
         "q",
         "location",
-        "group_id",
+        "vlan_group",
         "status",
         "role",
         "tenant_group",
         "tenant",
     ]
     q = forms.CharField(required=False, label="Search")
-    group_id = DynamicModelMultipleChoiceField(
+    vlan_group = DynamicModelMultipleChoiceField(
         queryset=VLANGroup.objects.all(),
         required=False,
         label="VLAN group",


Test patch:
diff --git a/nautobot/core/testing/forms.py b/nautobot/core/testing/forms.py
new file mode 100644
--- /dev/null
+++ b/nautobot/core/testing/forms.py
@@ -0,0 +1,30 @@
+from django.test import tag, TestCase
+
+from nautobot.core.forms.fields import DynamicModelChoiceMixin
+from nautobot.core.utils.lookup import get_filterset_for_model
+
+
+# TODO(timizuo): All Form Test cases should inherit from FormTestCases
+@tag("unit")
+class FormTestCases:
+    class BaseFormTestCase(TestCase):
+        """Base class for generic form tests."""
+
+        form_class = None
+
+        def test_form_dynamic_model_choice_fields_query_params(self):
+            for field_name, field_class in self.form_class.declared_fields.items():
+                if not isinstance(field_class, DynamicModelChoiceMixin):
+                    continue
+                with self.subTest(f"Assert {self.form_class.__name__}.{field_name} query_params are valid."):
+                    query_params_fields = set(field_class.query_params.keys())
+                    if not query_params_fields:
+                        self.skipTest(f"{self.form_class.__name__}.{field_name} has no query_params")
+                    field_model = field_class.queryset.model
+                    filterset_class = get_filterset_for_model(field_model)
+                    filterset_fields = set(filterset_class.declared_filters.keys())
+                    invalid_query_params = query_params_fields - filterset_fields
+                    self.assertFalse(
+                        invalid_query_params,
+                        f"{invalid_query_params} are invalid query_params fields for {self.form_class.__name__}.{field_name}",
+                    )
diff --git a/nautobot/dcim/tests/test_forms.py b/nautobot/dcim/tests/test_forms.py
--- a/nautobot/dcim/tests/test_forms.py
+++ b/nautobot/dcim/tests/test_forms.py
@@ -1,5 +1,6 @@
 from django.test import TestCase
 
+from nautobot.core.testing.forms import FormTestCases
 from nautobot.dcim.choices import DeviceFaceChoices, InterfaceTypeChoices, RackWidthChoices
 from nautobot.dcim.forms import DeviceFilterForm, DeviceForm, InterfaceCreateForm, RackForm
 from nautobot.dcim.models import (
@@ -17,7 +18,9 @@
 from nautobot.virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
-class DeviceTestCase(TestCase):
+class DeviceTestCase(FormTestCases.BaseFormTestCase):
+    form_class = DeviceForm
+
     def setUp(self):
         self.device_status = Status.objects.get_for_model(Device).first()
 
diff --git a/nautobot/ipam/tests/test_forms.py b/nautobot/ipam/tests/test_forms.py
--- a/nautobot/ipam/tests/test_forms.py
+++ b/nautobot/ipam/tests/test_forms.py
@@ -2,6 +2,7 @@
 
 from django.test import TestCase
 
+from nautobot.core.testing.forms import FormTestCases
 from nautobot.extras.models import Status
 from nautobot.ipam import forms, models
 from nautobot.ipam.choices import IPAddressTypeChoices
@@ -63,7 +64,7 @@ def test_address_missing_mask(self):
         self.assertEqual("CIDR mask (e.g. /24) is required.", form.errors[self.field_name][0])
 
 
-class PrefixFormTest(BaseNetworkFormTest, TestCase):
+class PrefixFormTest(BaseNetworkFormTest, FormTestCases.BaseFormTestCase):
     form_class = forms.PrefixForm
     field_name = "prefix"
     object_name = "prefix"