filtersets.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import django_filters
  2. from copy import deepcopy
  3. from django.contrib.contenttypes.models import ContentType
  4. from django.db import models
  5. from django_filters.exceptions import FieldLookupError
  6. from django_filters.utils import get_model_field, resolve_field
  7. from extras.choices import CustomFieldFilterLogicChoices
  8. from extras.filters import TagFilter
  9. from extras.models import CustomField
  10. from utilities.constants import (
  11. FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
  12. FILTER_NUMERIC_BASED_LOOKUP_MAP
  13. )
  14. from utilities.forms import MACAddressField
  15. from utilities import filters
  16. __all__ = (
  17. 'BaseFilterSet',
  18. 'ChangeLoggedModelFilterSet',
  19. 'OrganizationalModelFilterSet',
  20. 'PrimaryModelFilterSet',
  21. )
  22. #
  23. # FilterSets
  24. #
  25. class BaseFilterSet(django_filters.FilterSet):
  26. """
  27. A base FilterSet which provides common functionality to all NetBox FilterSets
  28. """
  29. FILTER_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS)
  30. FILTER_DEFAULTS.update({
  31. models.AutoField: {
  32. 'filter_class': filters.MultiValueNumberFilter
  33. },
  34. models.CharField: {
  35. 'filter_class': filters.MultiValueCharFilter
  36. },
  37. models.DateField: {
  38. 'filter_class': filters.MultiValueDateFilter
  39. },
  40. models.DateTimeField: {
  41. 'filter_class': filters.MultiValueDateTimeFilter
  42. },
  43. models.DecimalField: {
  44. 'filter_class': filters.MultiValueNumberFilter
  45. },
  46. models.EmailField: {
  47. 'filter_class': filters.MultiValueCharFilter
  48. },
  49. models.FloatField: {
  50. 'filter_class': filters.MultiValueNumberFilter
  51. },
  52. models.IntegerField: {
  53. 'filter_class': filters.MultiValueNumberFilter
  54. },
  55. models.PositiveIntegerField: {
  56. 'filter_class': filters.MultiValueNumberFilter
  57. },
  58. models.PositiveSmallIntegerField: {
  59. 'filter_class': filters.MultiValueNumberFilter
  60. },
  61. models.SlugField: {
  62. 'filter_class': filters.MultiValueCharFilter
  63. },
  64. models.SmallIntegerField: {
  65. 'filter_class': filters.MultiValueNumberFilter
  66. },
  67. models.TimeField: {
  68. 'filter_class': filters.MultiValueTimeFilter
  69. },
  70. models.URLField: {
  71. 'filter_class': filters.MultiValueCharFilter
  72. },
  73. MACAddressField: {
  74. 'filter_class': filters.MultiValueMACAddressFilter
  75. },
  76. })
  77. @staticmethod
  78. def _get_filter_lookup_dict(existing_filter):
  79. # Choose the lookup expression map based on the filter type
  80. if isinstance(existing_filter, (
  81. django_filters.NumberFilter,
  82. filters.MultiValueDateFilter,
  83. filters.MultiValueDateTimeFilter,
  84. filters.MultiValueNumberFilter,
  85. filters.MultiValueTimeFilter
  86. )):
  87. return FILTER_NUMERIC_BASED_LOOKUP_MAP
  88. elif isinstance(existing_filter, (
  89. filters.TreeNodeMultipleChoiceFilter,
  90. )):
  91. # TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression
  92. return FILTER_TREENODE_NEGATION_LOOKUP_MAP
  93. elif isinstance(existing_filter, (
  94. django_filters.ModelChoiceFilter,
  95. django_filters.ModelMultipleChoiceFilter,
  96. TagFilter
  97. )) or existing_filter.extra.get('choices'):
  98. # These filter types support only negation
  99. return FILTER_NEGATION_LOOKUP_MAP
  100. elif isinstance(existing_filter, (
  101. django_filters.filters.CharFilter,
  102. django_filters.MultipleChoiceFilter,
  103. filters.MultiValueCharFilter,
  104. filters.MultiValueMACAddressFilter
  105. )):
  106. return FILTER_CHAR_BASED_LOOKUP_MAP
  107. return None
  108. @classmethod
  109. def get_additional_lookups(cls, existing_filter_name, existing_filter):
  110. new_filters = {}
  111. # Skip nonstandard lookup expressions
  112. if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']:
  113. return {}
  114. # Choose the lookup expression map based on the filter type
  115. lookup_map = cls._get_filter_lookup_dict(existing_filter)
  116. if lookup_map is None:
  117. # Do not augment this filter type with more lookup expressions
  118. return {}
  119. # Get properties of the existing filter for later use
  120. field_name = existing_filter.field_name
  121. field = get_model_field(cls._meta.model, field_name)
  122. # Create new filters for each lookup expression in the map
  123. for lookup_name, lookup_expr in lookup_map.items():
  124. new_filter_name = f'{existing_filter_name}__{lookup_name}'
  125. try:
  126. if existing_filter_name in cls.declared_filters:
  127. # The filter field has been explicitly defined on the filterset class so we must manually
  128. # create the new filter with the same type because there is no guarantee the defined type
  129. # is the same as the default type for the field
  130. resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
  131. new_filter = type(existing_filter)(
  132. field_name=field_name,
  133. lookup_expr=lookup_expr,
  134. label=existing_filter.label,
  135. exclude=existing_filter.exclude,
  136. distinct=existing_filter.distinct,
  137. **existing_filter.extra
  138. )
  139. elif hasattr(existing_filter, 'custom_field'):
  140. # Filter is for a custom field
  141. custom_field = existing_filter.custom_field
  142. new_filter = custom_field.to_filter(lookup_expr=lookup_expr)
  143. else:
  144. # The filter field is listed in Meta.fields so we can safely rely on default behaviour
  145. # Will raise FieldLookupError if the lookup is invalid
  146. new_filter = cls.filter_for_field(field, field_name, lookup_expr)
  147. except FieldLookupError:
  148. # The filter could not be created because the lookup expression is not supported on the field
  149. continue
  150. if lookup_name.startswith('n'):
  151. # This is a negation filter which requires a queryset.exclude() clause
  152. # Of course setting the negation of the existing filter's exclude attribute handles both cases
  153. new_filter.exclude = not existing_filter.exclude
  154. new_filters[new_filter_name] = new_filter
  155. return new_filters
  156. @classmethod
  157. def get_filters(cls):
  158. """
  159. Override filter generation to support dynamic lookup expressions for certain filter types.
  160. For specific filter types, new filters are created based on defined lookup expressions in
  161. the form `<field_name>__<lookup_expr>`
  162. """
  163. filters = super().get_filters()
  164. additional_filters = {}
  165. for existing_filter_name, existing_filter in filters.items():
  166. additional_filters.update(cls.get_additional_lookups(existing_filter_name, existing_filter))
  167. filters.update(additional_filters)
  168. return filters
  169. class ChangeLoggedModelFilterSet(BaseFilterSet):
  170. created = django_filters.DateFilter()
  171. created__gte = django_filters.DateFilter(
  172. field_name='created',
  173. lookup_expr='gte'
  174. )
  175. created__lte = django_filters.DateFilter(
  176. field_name='created',
  177. lookup_expr='lte'
  178. )
  179. last_updated = django_filters.DateTimeFilter()
  180. last_updated__gte = django_filters.DateTimeFilter(
  181. field_name='last_updated',
  182. lookup_expr='gte'
  183. )
  184. last_updated__lte = django_filters.DateTimeFilter(
  185. field_name='last_updated',
  186. lookup_expr='lte'
  187. )
  188. class PrimaryModelFilterSet(ChangeLoggedModelFilterSet):
  189. def __init__(self, *args, **kwargs):
  190. super().__init__(*args, **kwargs)
  191. # Dynamically add a Filter for each CustomField applicable to the parent model
  192. custom_fields = CustomField.objects.filter(
  193. content_types=ContentType.objects.get_for_model(self._meta.model)
  194. ).exclude(
  195. filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
  196. )
  197. custom_field_filters = {}
  198. for custom_field in custom_fields:
  199. filter_name = f'cf_{custom_field.name}'
  200. filter_instance = custom_field.to_filter()
  201. if filter_instance:
  202. custom_field_filters[filter_name] = filter_instance
  203. # Add relevant additional lookups
  204. additional_lookups = self.get_additional_lookups(filter_name, filter_instance)
  205. custom_field_filters.update(additional_lookups)
  206. self.filters.update(custom_field_filters)
  207. class OrganizationalModelFilterSet(PrimaryModelFilterSet):
  208. """
  209. A base class for adding the search method to models which only expose the `name` and `slug` fields
  210. """
  211. q = django_filters.CharFilter(
  212. method='search',
  213. label='Search',
  214. )
  215. def search(self, queryset, name, value):
  216. if not value.strip():
  217. return queryset
  218. return queryset.filter(
  219. models.Q(name__icontains=value) |
  220. models.Q(slug__icontains=value)
  221. )