浏览代码

Closes #23346: Validate model choice filter querysets in FilterSet tests (#23391)

Add shared checks for queryset model compatibility and to_field_name
values without querying the database. Handle related field paths,
primary-key aliases, proxy models, and tree filters.
Arthur Hanson 2 天之前
父节点
当前提交
3288b957c3
共有 1 个文件被更改,包括 90 次插入 和 0 次删除
  1. 90 0
      netbox/utilities/testing/filtersets.py

+ 90 - 0
netbox/utilities/testing/filtersets.py

@@ -4,7 +4,9 @@ from itertools import chain
 import django_filters
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
 from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import FieldDoesNotExist
 from django.db.models import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel, OneToOneRel
+from django.db.models.constants import LOOKUP_SEP
 from django.utils.module_loading import import_string
 from taggit.managers import TaggableManager
 
@@ -25,6 +27,29 @@ EXEMPT_MODEL_FIELDS = (
 )
 
 
+def get_filter_target(model, field_name):
+    """
+    Return a (model, field) tuple for a filter's field_name, or (None, None) if it cannot be resolved (e.g. an
+    annotation or GFK). A path ending in a relation (e.g. site) targets the related model; a path ending in a scalar
+    field (e.g. site__slug) targets the model which owns that field. In either case, field is the final field in the
+    path.
+    """
+    field = None
+    for part in field_name.split(LOOKUP_SEP):
+        if part == 'pk':
+            return model, model._meta.pk
+        try:
+            field = model._meta.get_field(part)
+        except FieldDoesNotExist:
+            return None, None
+        if not field.is_relation:
+            return model, field
+        if field.related_model is None:
+            return None, None
+        model = field.related_model
+    return model, field
+
+
 class BaseFilterSetTestMixin:
     queryset = None
     filterset = None
@@ -149,6 +174,71 @@ class BaseFilterSetTestMixin:
                         f"Invalid filter class {type(filter)} for {filter_name} (should be {filter_class})!"
                     )
 
+    def test_model_choice_filters(self):
+        """
+        Check that each model choice filter's queryset and to_field_name are consistent with its field_name.
+        """
+        model = self.filterset.Meta.model
+
+        for filter_name, filter in self.filterset.base_filters.items():
+            if not isinstance(filter, (django_filters.ModelChoiceFilter, django_filters.ModelMultipleChoiceFilter)):
+                continue
+            queryset = filter.extra.get('queryset')
+            if queryset is None or callable(queryset):
+                continue
+            queryset_model = queryset.model
+
+            with self.subTest(filter=filter_name):
+                to_field_name = filter.extra.get('to_field_name')
+                if to_field_name and to_field_name != 'pk':
+                    try:
+                        queryset_model._meta.get_field(to_field_name)
+                    except FieldDoesNotExist:
+                        self.fail(
+                            f"Invalid to_field_name '{to_field_name}' for {filter_name} "
+                            f"({queryset_model.__name__} has no such field)!"
+                        )
+
+                # Filters with a custom method may use any queryset
+                if filter.method is not None:
+                    continue
+                target_model, target_field = get_filter_target(model, filter.field_name)
+                if target_model is None:
+                    continue
+                self.assertTrue(
+                    issubclass(queryset_model, target_model) or
+                    queryset_model._meta.concrete_model is target_model._meta.concrete_model,
+                    f"Invalid queryset model {queryset_model.__name__} for {filter_name} "
+                    f"(field_name '{filter.field_name}' targets {target_model.__name__})!"
+                )
+
+                if target_field.is_relation:
+                    # Tree filters match descendants by queryset rather than by to_field_name value
+                    if isinstance(filter, TreeNodeMultipleChoiceFilter) or type(target_field) is GenericRelation:
+                        continue
+                    # Only multiple-choice filters with an explicit to_field_name pass that attribute's value to the
+                    # lookup; single-choice filters and those without to_field_name pass the model instance itself
+                    if not isinstance(filter, django_filters.ModelMultipleChoiceFilter) or to_field_name is None:
+                        continue
+                    # A field_name ending in a relation is matched against the relation's target field
+                    expected_field = target_field.target_field
+                    msg = f"to_field_name for {filter_name} must match the target field of '{filter.field_name}'!"
+                else:
+                    # A field_name ending in a scalar field is matched against the to_field_name value
+                    self.assertIsInstance(
+                        filter,
+                        django_filters.ModelMultipleChoiceFilter,
+                        f"ModelChoiceFilter {filter_name} cannot use a scalar field_name '{filter.field_name}'!"
+                    )
+                    expected_field = target_field
+                    msg = f"to_field_name for {filter_name} must match field_name '{filter.field_name}'!"
+
+                # to_field_name defaults to 'pk', which may also be given explicitly (or by its actual name)
+                if to_field_name in (None, 'pk'):
+                    self.assertTrue(expected_field.primary_key, msg)
+                else:
+                    self.assertEqual(to_field_name, expected_field.name, msg)
+
 
 class ChangeLoggedFilterSetTestMixin(BaseFilterSetTestMixin):