import copy from collections import OrderedDict from django import forms from django.db import models from django.db.models.constants import LOOKUP_SEP from django.db.models.fields.related import ManyToManyRel, ManyToOneRel, OneToOneRel from django.utils.datastructures import MultiValueDict from .conf import settings from .constants import ALL_FIELDS from .filters import ( BaseInFilter, BaseRangeFilter, BooleanFilter, CharFilter, ChoiceFilter, DateFilter, DateTimeFilter, DurationFilter, Filter, ModelChoiceFilter, ModelMultipleChoiceFilter, NumberFilter, TimeFilter, UUIDFilter, ) from .utils import get_all_model_fields, get_model_field, resolve_field, try_dbfield def remote_queryset(field): """ Get the queryset for the other side of a relationship. This works for both `RelatedField`s and `ForeignObjectRel`s. """ model = field.related_model # Reverse relationships do not have choice limits if not hasattr(field, "get_limit_choices_to"): return model._default_manager.all() limit_choices_to = field.get_limit_choices_to() return model._default_manager.complex_filter(limit_choices_to) class FilterSetOptions: def __init__(self, options=None): self.model = getattr(options, "model", None) self.fields = getattr(options, "fields", None) self.exclude = getattr(options, "exclude", None) self.filter_overrides = getattr(options, "filter_overrides", {}) self.form = getattr(options, "form", forms.Form) class FilterSetMetaclass(type): def __new__(cls, name, bases, attrs): attrs["declared_filters"] = cls.get_declared_filters(bases, attrs) new_class = super().__new__(cls, name, bases, attrs) new_class._meta = FilterSetOptions(getattr(new_class, "Meta", None)) new_class.base_filters = new_class.get_filters() return new_class @classmethod def get_declared_filters(cls, bases, attrs): filters = [ (filter_name, attrs.pop(filter_name)) for filter_name, obj in list(attrs.items()) if isinstance(obj, Filter) ] # Default the `filter.field_name` to the attribute name on the filterset for filter_name, f in filters: if getattr(f, "field_name", None) is None: f.field_name = filter_name filters.sort(key=lambda x: x[1].creation_counter) # Ensures a base class field doesn't override cls attrs, and maintains # field precedence when inheriting multiple parents. e.g. if there is a # class C(A, B), and A and B both define 'field', use 'field' from A. known = set(attrs) def visit(name): known.add(name) return name base_filters = [ (visit(name), f) for base in bases if hasattr(base, "declared_filters") for name, f in base.declared_filters.items() if name not in known ] return OrderedDict(base_filters + filters) FILTER_FOR_DBFIELD_DEFAULTS = { models.AutoField: {"filter_class": NumberFilter}, models.CharField: {"filter_class": CharFilter}, models.TextField: {"filter_class": CharFilter}, models.BooleanField: {"filter_class": BooleanFilter}, models.DateField: {"filter_class": DateFilter}, models.DateTimeField: {"filter_class": DateTimeFilter}, models.TimeField: {"filter_class": TimeFilter}, models.DurationField: {"filter_class": DurationFilter}, models.DecimalField: {"filter_class": NumberFilter}, models.SmallIntegerField: {"filter_class": NumberFilter}, models.IntegerField: {"filter_class": NumberFilter}, models.PositiveIntegerField: {"filter_class": NumberFilter}, models.PositiveSmallIntegerField: {"filter_class": NumberFilter}, models.FloatField: {"filter_class": NumberFilter}, models.NullBooleanField: {"filter_class": BooleanFilter}, models.SlugField: {"filter_class": CharFilter}, models.EmailField: {"filter_class": CharFilter}, models.FilePathField: {"filter_class": CharFilter}, models.URLField: {"filter_class": CharFilter}, models.GenericIPAddressField: {"filter_class": CharFilter}, models.CommaSeparatedIntegerField: {"filter_class": CharFilter}, models.UUIDField: {"filter_class": UUIDFilter}, # Forward relationships models.OneToOneField: { "filter_class": ModelChoiceFilter, "extra": lambda f: { "queryset": remote_queryset(f), "to_field_name": f.remote_field.field_name, "null_label": settings.NULL_CHOICE_LABEL if f.null else None, }, }, models.ForeignKey: { "filter_class": ModelChoiceFilter, "extra": lambda f: { "queryset": remote_queryset(f), "to_field_name": f.remote_field.field_name, "null_label": settings.NULL_CHOICE_LABEL if f.null else None, }, }, models.ManyToManyField: { "filter_class": ModelMultipleChoiceFilter, "extra": lambda f: { "queryset": remote_queryset(f), }, }, # Reverse relationships OneToOneRel: { "filter_class": ModelChoiceFilter, "extra": lambda f: { "queryset": remote_queryset(f), "null_label": settings.NULL_CHOICE_LABEL if f.null else None, }, }, ManyToOneRel: { "filter_class": ModelMultipleChoiceFilter, "extra": lambda f: { "queryset": remote_queryset(f), }, }, ManyToManyRel: { "filter_class": ModelMultipleChoiceFilter, "extra": lambda f: { "queryset": remote_queryset(f), }, }, } class BaseFilterSet: FILTER_DEFAULTS = FILTER_FOR_DBFIELD_DEFAULTS def __init__(self, data=None, queryset=None, *, request=None, prefix=None): if queryset is None: queryset = self._meta.model._default_manager.all() model = queryset.model self.is_bound = data is not None self.data = data or MultiValueDict() self.queryset = queryset self.request = request self.form_prefix = prefix self.filters = copy.deepcopy(self.base_filters) # propagate the model and filterset to the filters for filter_ in self.filters.values(): filter_.model = model filter_.parent = self def is_valid(self): """ Return True if the underlying form has no errors, or False otherwise. """ return self.is_bound and self.form.is_valid() @property def errors(self): """ Return an ErrorDict for the data provided for the underlying form. """ return self.form.errors def filter_queryset(self, queryset): """ Filter the queryset with the underlying form's `cleaned_data`. You must call `is_valid()` or `errors` before calling this method. This method should be overridden if additional filtering needs to be applied to the queryset before it is cached. """ for name, value in self.form.cleaned_data.items(): queryset = self.filters[name].filter(queryset, value) assert isinstance( queryset, models.QuerySet ), "Expected '%s.%s' to return a QuerySet, but got a %s instead." % ( type(self).__name__, name, type(queryset).__name__, ) return queryset @property def qs(self): if not hasattr(self, "_qs"): qs = self.queryset.all() if self.is_bound: # ensure form validation before filtering self.errors qs = self.filter_queryset(qs) self._qs = qs return self._qs def get_form_class(self): """ Returns a django Form suitable of validating the filterset data. This method should be overridden if the form class needs to be customized relative to the filterset instance. """ fields = OrderedDict( [(name, filter_.field) for name, filter_ in self.filters.items()] ) return type(str("%sForm" % self.__class__.__name__), (self._meta.form,), fields) @property def form(self): if not hasattr(self, "_form"): Form = self.get_form_class() if self.is_bound: self._form = Form(self.data, prefix=self.form_prefix) else: self._form = Form(prefix=self.form_prefix) return self._form @classmethod def get_fields(cls): """ Resolve the 'fields' argument that should be used for generating filters on the filterset. This is 'Meta.fields' sans the fields in 'Meta.exclude'. """ model = cls._meta.model fields = cls._meta.fields exclude = cls._meta.exclude assert not (fields is None and exclude is None), ( "Setting 'Meta.model' without either 'Meta.fields' or 'Meta.exclude' " "has been deprecated since 0.15.0 and is now disallowed. Add an explicit " "'Meta.fields' or 'Meta.exclude' to the %s class." % cls.__name__ ) # Setting exclude with no fields implies all other fields. if exclude is not None and fields is None: fields = ALL_FIELDS # Resolve ALL_FIELDS into all fields for the filterset's model. if fields == ALL_FIELDS: fields = get_all_model_fields(model) # Remove excluded fields exclude = exclude or [] if not isinstance(fields, dict): fields = [ (f, [settings.DEFAULT_LOOKUP_EXPR]) for f in fields if f not in exclude ] else: fields = [(f, lookups) for f, lookups in fields.items() if f not in exclude] return OrderedDict(fields) @classmethod def get_filter_name(cls, field_name, lookup_expr): """ Combine a field name and lookup expression into a usable filter name. Exact lookups are the implicit default, so "exact" is stripped from the end of the filter name. """ filter_name = LOOKUP_SEP.join([field_name, lookup_expr]) # This also works with transformed exact lookups, such as 'date__exact' _default_expr = LOOKUP_SEP + settings.DEFAULT_LOOKUP_EXPR if filter_name.endswith(_default_expr): filter_name = filter_name[: -len(_default_expr)] return filter_name @classmethod def get_filters(cls): """ Get all filters for the filterset. This is the combination of declared and generated filters. """ # No model specified - skip filter generation if not cls._meta.model: return cls.declared_filters.copy() # Determine the filters that should be included on the filterset. filters = OrderedDict() fields = cls.get_fields() undefined = [] for field_name, lookups in fields.items(): field = get_model_field(cls._meta.model, field_name) # warn if the field doesn't exist. if field is None: undefined.append(field_name) for lookup_expr in lookups: filter_name = cls.get_filter_name(field_name, lookup_expr) # If the filter is explicitly declared on the class, skip generation if filter_name in cls.declared_filters: filters[filter_name] = cls.declared_filters[filter_name] continue if field is not None: filters[filter_name] = cls.filter_for_field( field, field_name, lookup_expr ) # Allow Meta.fields to contain declared filters *only* when a list/tuple if isinstance(cls._meta.fields, (list, tuple)): undefined = [f for f in undefined if f not in cls.declared_filters] if undefined: raise TypeError( "'Meta.fields' must not contain non-model field names: %s" % ", ".join(undefined) ) # Add in declared filters. This is necessary since we don't enforce adding # declared filters to the 'Meta.fields' option filters.update(cls.declared_filters) return filters @classmethod def filter_for_field(cls, field, field_name, lookup_expr=None): if lookup_expr is None: lookup_expr = settings.DEFAULT_LOOKUP_EXPR field, lookup_type = resolve_field(field, lookup_expr) default = { "field_name": field_name, "lookup_expr": lookup_expr, } filter_class, params = cls.filter_for_lookup(field, lookup_type) default.update(params) assert filter_class is not None, ( "%s resolved field '%s' with '%s' lookup to an unrecognized field " "type %s. Try adding an override to 'Meta.filter_overrides'. See: " "https://django-filter.readthedocs.io/en/main/ref/filterset.html" "#customise-filter-generation-with-filter-overrides" ) % (cls.__name__, field_name, lookup_expr, field.__class__.__name__) return filter_class(**default) @classmethod def filter_for_lookup(cls, field, lookup_type): DEFAULTS = dict(cls.FILTER_DEFAULTS) if hasattr(cls, "_meta"): DEFAULTS.update(cls._meta.filter_overrides) data = try_dbfield(DEFAULTS.get, field.__class__) or {} filter_class = data.get("filter_class") params = data.get("extra", lambda field: {})(field) # if there is no filter class, exit early if not filter_class: return None, {} # perform lookup specific checks if lookup_type == "exact" and getattr(field, "choices", None): return ChoiceFilter, {"choices": field.choices} if lookup_type == "isnull": data = try_dbfield(DEFAULTS.get, models.BooleanField) filter_class = data.get("filter_class") params = data.get("extra", lambda field: {})(field) return filter_class, params if lookup_type == "in": class ConcreteInFilter(BaseInFilter, filter_class): pass ConcreteInFilter.__name__ = cls._csv_filter_class_name( filter_class, lookup_type ) return ConcreteInFilter, params if lookup_type == "range": class ConcreteRangeFilter(BaseRangeFilter, filter_class): pass ConcreteRangeFilter.__name__ = cls._csv_filter_class_name( filter_class, lookup_type ) return ConcreteRangeFilter, params return filter_class, params @classmethod def _csv_filter_class_name(cls, filter_class, lookup_type): """ Generate a suitable class name for a concrete filter class. This is not completely reliable, as not all filter class names are of the format Filter. ex:: FilterSet._csv_filter_class_name(DateTimeFilter, 'in') returns 'DateTimeInFilter' """ # DateTimeFilter => DateTime type_name = filter_class.__name__ if type_name.endswith("Filter"): type_name = type_name[:-6] # in => In lookup_name = lookup_type.capitalize() # DateTimeInFilter return str("%s%sFilter" % (type_name, lookup_name)) class FilterSet(BaseFilterSet, metaclass=FilterSetMetaclass): pass def filterset_factory(model, filterset=FilterSet, fields=None): attrs = {"model": model} if fields is None: if getattr(getattr(filterset, "Meta", {}), "fields", None) is None: attrs["fields"] = ALL_FIELDS else: attrs["fields"] = fields bases = (filterset.Meta,) if hasattr(filterset, "Meta") else () Meta = type("Meta", bases, attrs) return type(filterset)( str("%sFilterSet" % model._meta.object_name), (filterset,), {"Meta": Meta} )