|
@@ -4,7 +4,9 @@ from itertools import chain
|
|
|
import django_filters
|
|
import django_filters
|
|
|
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
|
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
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 import ForeignKey, ManyToManyField, ManyToManyRel, ManyToOneRel, OneToOneRel
|
|
|
|
|
+from django.db.models.constants import LOOKUP_SEP
|
|
|
from django.utils.module_loading import import_string
|
|
from django.utils.module_loading import import_string
|
|
|
from taggit.managers import TaggableManager
|
|
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:
|
|
class BaseFilterSetTestMixin:
|
|
|
queryset = None
|
|
queryset = None
|
|
|
filterset = None
|
|
filterset = None
|
|
@@ -149,6 +174,71 @@ class BaseFilterSetTestMixin:
|
|
|
f"Invalid filter class {type(filter)} for {filter_name} (should be {filter_class})!"
|
|
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):
|
|
class ChangeLoggedFilterSetTestMixin(BaseFilterSetTestMixin):
|
|
|
|
|
|