|
@@ -1,14 +1,21 @@
|
|
|
import json
|
|
import json
|
|
|
|
|
+from collections.abc import MutableMapping
|
|
|
from copy import deepcopy
|
|
from copy import deepcopy
|
|
|
|
|
|
|
|
import django_filters
|
|
import django_filters
|
|
|
|
|
+from django import forms
|
|
|
from django.contrib.auth.models import AnonymousUser
|
|
from django.contrib.auth.models import AnonymousUser
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.contrib.contenttypes.models import ContentType
|
|
|
|
|
+from django.core.exceptions import ValidationError
|
|
|
from django.db import models
|
|
from django.db import models
|
|
|
from django.db.models import Q
|
|
from django.db.models import Q
|
|
|
|
|
+from django.forms.utils import ErrorDict, ErrorList
|
|
|
|
|
+from django.http import QueryDict
|
|
|
|
|
+from django.utils.datastructures import MultiValueDict
|
|
|
from django.utils.translation import gettext as _
|
|
from django.utils.translation import gettext as _
|
|
|
from django_filters.exceptions import FieldLookupError
|
|
from django_filters.exceptions import FieldLookupError
|
|
|
from django_filters.utils import get_model_field, resolve_field
|
|
from django_filters.utils import get_model_field, resolve_field
|
|
|
|
|
+from django_filters.widgets import SuffixedMultiWidget
|
|
|
|
|
|
|
|
from core.choices import ObjectChangeActionChoices
|
|
from core.choices import ObjectChangeActionChoices
|
|
|
from core.models import ObjectChange
|
|
from core.models import ObjectChange
|
|
@@ -52,6 +59,167 @@ BOOLEAN_LOOKUPS = (
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+#
|
|
|
|
|
+# Filter resolution
|
|
|
|
|
+#
|
|
|
|
|
+
|
|
|
|
|
+def get_data_keys(name, filter_):
|
|
|
|
|
+ """
|
|
|
|
|
+ Return the keys of request data from which the given filter reads its value.
|
|
|
|
|
+ """
|
|
|
|
|
+ widget = filter_.extra.get('widget') or getattr(filter_.field_class, 'widget', None)
|
|
|
|
|
+ widget_class = widget if isinstance(widget, type) else type(widget)
|
|
|
|
|
+ if not issubclass(widget_class, forms.MultiWidget):
|
|
|
|
|
+ return [name]
|
|
|
|
|
+ if issubclass(widget_class, SuffixedMultiWidget):
|
|
|
|
|
+ return [f'{name}_{suffix}' if suffix else name for suffix in widget.suffixes]
|
|
|
|
|
+ return [f'{name}{suffix}' for suffix in deepcopy(filter_).field.widget.widgets_names]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class FilterIndex:
|
|
|
|
|
+ """
|
|
|
|
|
+ The filters of a FilterSet class, indexed for resolving request data.
|
|
|
|
|
+ """
|
|
|
|
|
+ def __init__(self, filters):
|
|
|
|
|
+ self.filters = filters
|
|
|
|
|
+ self.positions = {name: i for i, name in enumerate(filters)}
|
|
|
|
|
+ # Data keys which differ from the name of the filter reading them (e.g. "created_after")
|
|
|
|
|
+ self.data_keys = {
|
|
|
|
|
+ key: name
|
|
|
|
|
+ for name, filter_ in filters.items()
|
|
|
|
|
+ for key in get_data_keys(name, filter_)
|
|
|
|
|
+ if key != name
|
|
|
|
|
+ }
|
|
|
|
|
+ self.required = [name for name, filter_ in filters.items() if filter_.extra.get('required')]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class LazyFilters:
|
|
|
|
|
+ """
|
|
|
|
|
+ Filters built by calling a loader once a filter whose name begins with the given prefix is requested.
|
|
|
|
|
+ """
|
|
|
|
|
+ def __init__(self, prefix, loader):
|
|
|
|
|
+ self.prefix = prefix
|
|
|
|
|
+ self.loader = loader
|
|
|
|
|
+ self.names = None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class FilterDict(MutableMapping):
|
|
|
|
|
+ """
|
|
|
|
|
+ The filters of a FilterSet instance. Each of the class's filters is copied on first access, so that a request
|
|
|
|
|
+ copies only the filters it uses.
|
|
|
|
|
+ """
|
|
|
|
|
+ def __init__(self, filterset, index):
|
|
|
|
|
+ self.filterset = filterset
|
|
|
|
|
+ self.index = index
|
|
|
|
|
+ self.filters = {} # Filters copied or added for this instance
|
|
|
|
|
+ self.removed = set() # Names of class filters removed from this instance
|
|
|
|
|
+ self.extra = [] # Names of added filters, and LazyFilters, in order
|
|
|
|
|
+ self.extra_data_keys = {}
|
|
|
|
|
+
|
|
|
|
|
+ def __getitem__(self, name):
|
|
|
|
|
+ if name not in self.filters:
|
|
|
|
|
+ if self._is_class_filter(name):
|
|
|
|
|
+ filter_ = deepcopy(self.index.filters[name])
|
|
|
|
|
+ filter_.model = self.filterset.queryset.model
|
|
|
|
|
+ filter_.parent = self.filterset
|
|
|
|
|
+ self.filters[name] = filter_
|
|
|
|
|
+ elif not self._load(name):
|
|
|
|
|
+ raise KeyError(name)
|
|
|
|
|
+ return self.filters[name]
|
|
|
|
|
+
|
|
|
|
|
+ def __setitem__(self, name, filter_):
|
|
|
|
|
+ if name not in self:
|
|
|
|
|
+ self.extra.append(name)
|
|
|
|
|
+ self._add_data_keys(name, filter_)
|
|
|
|
|
+ self.filters[name] = filter_
|
|
|
|
|
+
|
|
|
|
|
+ def __delitem__(self, name):
|
|
|
|
|
+ if name not in self:
|
|
|
|
|
+ raise KeyError(name)
|
|
|
|
|
+ self.filters.pop(name, None)
|
|
|
|
|
+ if self._is_class_filter(name):
|
|
|
|
|
+ self.removed.add(name)
|
|
|
|
|
+ elif name in self.extra:
|
|
|
|
|
+ self.extra.remove(name)
|
|
|
|
|
+ else:
|
|
|
|
|
+ for entry in self.extra:
|
|
|
|
|
+ if isinstance(entry, LazyFilters) and name in (entry.names or []):
|
|
|
|
|
+ entry.names.remove(name)
|
|
|
|
|
+ self.extra_data_keys = {key: value for key, value in self.extra_data_keys.items() if value != name}
|
|
|
|
|
+
|
|
|
|
|
+ def __iter__(self):
|
|
|
|
|
+ self._load()
|
|
|
|
|
+ for name in self.index.filters:
|
|
|
|
|
+ if name not in self.removed:
|
|
|
|
|
+ yield name
|
|
|
|
|
+ yield from self._extra_names()
|
|
|
|
|
+
|
|
|
|
|
+ def __len__(self):
|
|
|
|
|
+ self._load()
|
|
|
|
|
+ return len(self.index.filters) - len(self.removed) + len(self._extra_names())
|
|
|
|
|
+
|
|
|
|
|
+ def __contains__(self, name):
|
|
|
|
|
+ return name in self.filters or self._is_class_filter(name) or self._load(name)
|
|
|
|
|
+
|
|
|
|
|
+ def add_lazy(self, prefix, loader):
|
|
|
|
|
+ """
|
|
|
|
|
+ Register a callable which returns filters named with the given prefix, to be called once one is needed.
|
|
|
|
|
+ """
|
|
|
|
|
+ self.extra.append(LazyFilters(prefix, loader))
|
|
|
|
|
+
|
|
|
|
|
+ def get_names(self, key):
|
|
|
|
|
+ """
|
|
|
|
|
+ Return the names of the filters which read the given key of the request data.
|
|
|
|
|
+ """
|
|
|
|
|
+ names = [key] if key in self else []
|
|
|
|
|
+ name = self.extra_data_keys.get(key) or self.index.data_keys.get(key)
|
|
|
|
|
+ if name is not None and name != key and name in self:
|
|
|
|
|
+ names.append(name)
|
|
|
|
|
+ return names
|
|
|
|
|
+
|
|
|
|
|
+ def position(self, name):
|
|
|
|
|
+ """
|
|
|
|
|
+ Return the position of the named filter in declaration order.
|
|
|
|
|
+ """
|
|
|
|
|
+ if self._is_class_filter(name):
|
|
|
|
|
+ return self.index.positions[name]
|
|
|
|
|
+ return len(self.index.positions) + self._extra_names().index(name)
|
|
|
|
|
+
|
|
|
|
|
+ def _is_class_filter(self, name):
|
|
|
|
|
+ return name in self.index.filters and name not in self.removed
|
|
|
|
|
+
|
|
|
|
|
+ def _extra_names(self):
|
|
|
|
|
+ names = []
|
|
|
|
|
+ for entry in self.extra:
|
|
|
|
|
+ if isinstance(entry, LazyFilters):
|
|
|
|
|
+ names.extend(entry.names or [])
|
|
|
|
|
+ else:
|
|
|
|
|
+ names.append(entry)
|
|
|
|
|
+ return names
|
|
|
|
|
+
|
|
|
|
|
+ def _add_data_keys(self, name, filter_):
|
|
|
|
|
+ for key in get_data_keys(name, filter_):
|
|
|
|
|
+ if key != name:
|
|
|
|
|
+ self.extra_data_keys[key] = name
|
|
|
|
|
+
|
|
|
|
|
+ def _load(self, name=None):
|
|
|
|
|
+ """
|
|
|
|
|
+ Call any pending loaders for the given filter name (or all of them). Return True if the name now exists.
|
|
|
|
|
+ """
|
|
|
|
|
+ for entry in self.extra:
|
|
|
|
|
+ if not isinstance(entry, LazyFilters) or entry.names is not None:
|
|
|
|
|
+ continue
|
|
|
|
|
+ if name is not None and not name.startswith(entry.prefix):
|
|
|
|
|
+ continue
|
|
|
|
|
+ entry.names = []
|
|
|
|
|
+ for filter_name, filter_ in entry.loader().items():
|
|
|
|
|
+ if filter_name not in self.filters and not self._is_class_filter(filter_name):
|
|
|
|
|
+ entry.names.append(filter_name)
|
|
|
|
|
+ self._add_data_keys(filter_name, filter_)
|
|
|
|
|
+ self.filters[filter_name] = filter_
|
|
|
|
|
+ return name is not None and name in self.filters
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
#
|
|
#
|
|
|
# FilterSets
|
|
# FilterSets
|
|
|
#
|
|
#
|
|
@@ -112,12 +280,10 @@ class BaseFilterSet(django_filters.FilterSet):
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- def __init__(self, data=None, *args, **kwargs):
|
|
|
|
|
- # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready
|
|
|
|
|
- # however FilterSet Factory is setup before this which creates the
|
|
|
|
|
- # initial filters. This recreates the filters so Empty is picked up correctly.
|
|
|
|
|
- self.base_filters = self.__class__.get_filters()
|
|
|
|
|
|
|
+ # Set to True to evaluate every filter on each request, as before NetBox v4.8 (to be revisited in v5.0)
|
|
|
|
|
+ legacy_evaluation = False
|
|
|
|
|
|
|
|
|
|
+ def __init__(self, data=None, queryset=None, *, request=None, prefix=None):
|
|
|
# Apply any referenced SavedFilters
|
|
# Apply any referenced SavedFilters
|
|
|
if data and ('filter' in data or 'filter_id' in data):
|
|
if data and ('filter' in data or 'filter_id' in data):
|
|
|
data = data.copy() # Get a mutable copy
|
|
data = data.copy() # Get a mutable copy
|
|
@@ -132,7 +298,6 @@ class BaseFilterSet(django_filters.FilterSet):
|
|
|
|
|
|
|
|
# Only apply SavedFilters the requesting user is permitted to see (#22790). Fall back to
|
|
# Only apply SavedFilters the requesting user is permitted to see (#22790). Fall back to
|
|
|
# anonymous visibility (shared filters only) when no request is available.
|
|
# anonymous visibility (shared filters only) when no request is available.
|
|
|
- request = kwargs.get('request')
|
|
|
|
|
user = request.user if request else AnonymousUser()
|
|
user = request.user if request else AnonymousUser()
|
|
|
saved_filters = SavedFilter.objects.restrict_to_shared(user).filter(
|
|
saved_filters = SavedFilter.objects.restrict_to_shared(user).filter(
|
|
|
Q(slug__in=data.pop('filter', [])) |
|
|
Q(slug__in=data.pop('filter', [])) |
|
|
@@ -149,7 +314,105 @@ class BaseFilterSet(django_filters.FilterSet):
|
|
|
else:
|
|
else:
|
|
|
data.setlist(key, value)
|
|
data.setlist(key, value)
|
|
|
|
|
|
|
|
- super().__init__(data, *args, **kwargs)
|
|
|
|
|
|
|
+ if self.legacy_evaluation or prefix is not None or self._has_custom_form():
|
|
|
|
|
+ # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready
|
|
|
|
|
+ # however FilterSet Factory is setup before this which creates the
|
|
|
|
|
+ # initial filters. This recreates the filters so Empty is picked up correctly.
|
|
|
|
|
+ self.base_filters = self.__class__.get_filters()
|
|
|
|
|
+ super().__init__(data, queryset, request=request, prefix=prefix)
|
|
|
|
|
+ return
|
|
|
|
|
+
|
|
|
|
|
+ # Copy each filter only once it's used, rather than deep-copying all of them as django-filter does
|
|
|
|
|
+ if queryset is None:
|
|
|
|
|
+ queryset = self._meta.model._default_manager.all()
|
|
|
|
|
+ self.is_bound = data is not None
|
|
|
|
|
+ self.data = data or QueryDict()
|
|
|
|
|
+ self.queryset = queryset
|
|
|
|
|
+ self.request = request
|
|
|
|
|
+ self.form_prefix = prefix
|
|
|
|
|
+ self.filters = FilterDict(self, self._get_filter_index())
|
|
|
|
|
+
|
|
|
|
|
+ @classmethod
|
|
|
|
|
+ def _get_filter_index(cls):
|
|
|
|
|
+ """
|
|
|
|
|
+ Return the class's filters, generated on first use (after Empty has been registered; see #9231). A class
|
|
|
|
|
+ which overrides get_filters() regenerates them on every call.
|
|
|
|
|
+ """
|
|
|
|
|
+ if cls.get_filters.__func__ is not BaseFilterSet.get_filters.__func__:
|
|
|
|
|
+ return FilterIndex(cls.get_filters())
|
|
|
|
|
+ if '_filter_index' not in cls.__dict__:
|
|
|
|
|
+ cls._filter_index = FilterIndex(cls.get_filters())
|
|
|
|
|
+ return cls._filter_index
|
|
|
|
|
+
|
|
|
|
|
+ @classmethod
|
|
|
|
|
+ def _has_custom_form(cls):
|
|
|
|
|
+ return cls._meta.form is not forms.Form or cls.get_form_class is not django_filters.FilterSet.get_form_class
|
|
|
|
|
+
|
|
|
|
|
+ @property
|
|
|
|
|
+ def _lazy(self):
|
|
|
|
|
+ # False under legacy evaluation, or if the filters have been replaced
|
|
|
|
|
+ return isinstance(self.filters, FilterDict)
|
|
|
|
|
+
|
|
|
|
|
+ @property
|
|
|
|
|
+ def form(self):
|
|
|
|
|
+ form = super().form
|
|
|
|
|
+ if self._lazy and self.is_bound:
|
|
|
|
|
+ # Evaluation no longer validates the form, so validate it here for code which reads its cleaned_data
|
|
|
|
|
+ form.is_valid()
|
|
|
|
|
+ return form
|
|
|
|
|
+
|
|
|
|
|
+ @property
|
|
|
|
|
+ def errors(self):
|
|
|
|
|
+ if not self._lazy:
|
|
|
|
|
+ return super().errors
|
|
|
|
|
+ self._clean()
|
|
|
|
|
+ return self._errors
|
|
|
|
|
+
|
|
|
|
|
+ def is_valid(self):
|
|
|
|
|
+ return self.is_bound and not self.errors
|
|
|
|
|
+
|
|
|
|
|
+ @property
|
|
|
|
|
+ def cleaned_data(self):
|
|
|
|
|
+ """
|
|
|
|
|
+ The cleaned values of the filters for which the request supplies data. For internal use only.
|
|
|
|
|
+ """
|
|
|
|
|
+ if not self._lazy:
|
|
|
|
|
+ self.form.is_valid()
|
|
|
|
|
+ return getattr(self.form, 'cleaned_data', {})
|
|
|
|
|
+ self._clean()
|
|
|
|
|
+ return self._cleaned_data
|
|
|
|
|
+
|
|
|
|
|
+ def filter_queryset(self, queryset):
|
|
|
|
|
+ if not self._lazy:
|
|
|
|
|
+ return super().filter_queryset(queryset)
|
|
|
|
|
+ for name, value in self.cleaned_data.items():
|
|
|
|
|
+ queryset = self.filters[name].filter(queryset, value)
|
|
|
|
|
+ assert isinstance(queryset, models.QuerySet), (
|
|
|
|
|
+ f"Expected '{type(self).__name__}.{name}' to return a QuerySet, but got a "
|
|
|
|
|
+ f"{type(queryset).__name__} instead."
|
|
|
|
|
+ )
|
|
|
|
|
+ return queryset
|
|
|
|
|
+
|
|
|
|
|
+ def _clean(self):
|
|
|
|
|
+ """
|
|
|
|
|
+ Clean the request's value for each filter it supplies, in declaration order.
|
|
|
|
|
+ """
|
|
|
|
|
+ if hasattr(self, '_cleaned_data'):
|
|
|
|
|
+ return
|
|
|
|
|
+ self._cleaned_data = {}
|
|
|
|
|
+ self._errors = ErrorDict()
|
|
|
|
|
+ if not self.is_bound:
|
|
|
|
|
+ return
|
|
|
|
|
+ names = {name for name in self.filters.index.required if name in self.filters}
|
|
|
|
|
+ for key in self.data.keys():
|
|
|
|
|
+ names.update(self.filters.get_names(key))
|
|
|
|
|
+ files = MultiValueDict()
|
|
|
|
|
+ for name in sorted(names, key=self.filters.position):
|
|
|
|
|
+ field = self.filters[name].field
|
|
|
|
|
+ try:
|
|
|
|
|
+ self._cleaned_data[name] = field.clean(field.widget.value_from_datadict(self.data, files, name))
|
|
|
|
|
+ except ValidationError as e:
|
|
|
|
|
+ self._errors[name] = ErrorList(e.error_list)
|
|
|
|
|
|
|
|
@staticmethod
|
|
@staticmethod
|
|
|
def _get_filter_lookup_dict(existing_filter):
|
|
def _get_filter_lookup_dict(existing_filter):
|
|
@@ -351,6 +614,16 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
|
|
|
def __init__(self, *args, **kwargs):
|
|
def __init__(self, *args, **kwargs):
|
|
|
super().__init__(*args, **kwargs)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
|
|
|
|
|
+ if isinstance(self.filters, FilterDict):
|
|
|
|
|
+ # Build custom field filters only once one is needed
|
|
|
|
|
+ self.filters.add_lazy('cf_', self._get_custom_field_filters)
|
|
|
|
|
+ else:
|
|
|
|
|
+ self.filters.update(self._get_custom_field_filters())
|
|
|
|
|
+
|
|
|
|
|
+ def _get_custom_field_filters(self):
|
|
|
|
|
+ """
|
|
|
|
|
+ Return filters for the model's custom fields, including their additional lookups.
|
|
|
|
|
+ """
|
|
|
custom_field_filters = {}
|
|
custom_field_filters = {}
|
|
|
for custom_field in CustomField.objects.get_for_model(self._meta.model):
|
|
for custom_field in CustomField.objects.get_for_model(self._meta.model):
|
|
|
if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_DISABLED:
|
|
if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_DISABLED:
|
|
@@ -364,7 +637,7 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
|
|
|
additional_lookups = self.get_additional_lookups(filter_name, filter_instance)
|
|
additional_lookups = self.get_additional_lookups(filter_name, filter_instance)
|
|
|
custom_field_filters.update(additional_lookups)
|
|
custom_field_filters.update(additional_lookups)
|
|
|
|
|
|
|
|
- self.filters.update(custom_field_filters)
|
|
|
|
|
|
|
+ return custom_field_filters
|
|
|
|
|
|
|
|
def search(self, queryset, name, value):
|
|
def search(self, queryset, name, value):
|
|
|
"""
|
|
"""
|