initial work on dynamic lookup expressions

This commit is contained in:
John Anderson 2020-02-09 03:20:59 -05:00
parent 202a0a0e73
commit a311002141
6 changed files with 166 additions and 34 deletions

View File

@ -4,7 +4,9 @@ from django.db.models import Q
from dcim.models import Region, Site from dcim.models import Region, Site
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet from tenancy.filters import TenancyFilterSet
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter from utilities.filters import (
BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
)
from .choices import * from .choices import *
from .models import Circuit, CircuitTermination, CircuitType, Provider from .models import Circuit, CircuitTermination, CircuitType, Provider
@ -16,7 +18,7 @@ __all__ = (
) )
class ProviderFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): class ProviderFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -65,14 +67,14 @@ class ProviderFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
) )
class CircuitTypeFilterSet(NameSlugSearchFilterSet): class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class CircuitFilterSet(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet): class CircuitFilterSet(BaseFilterSet, CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -146,7 +148,7 @@ class CircuitFilterSet(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFil
).distinct() ).distinct()
class CircuitTerminationFilterSet(django_filters.FilterSet): class CircuitTerminationFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',

View File

@ -6,8 +6,8 @@ from tenancy.filters import TenancyFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.constants import COLOR_CHOICES from utilities.constants import COLOR_CHOICES
from utilities.filters import ( from utilities.filters import (
MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, BaseFilterSet, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter,
TagFilter, TreeNodeMultipleChoiceFilter, BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
) )
from virtualization.models import Cluster from virtualization.models import Cluster
from .choices import * from .choices import *
@ -60,7 +60,7 @@ __all__ = (
) )
class RegionFilterSet(NameSlugSearchFilterSet): class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter( parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.all(),
label='Parent region (ID)', label='Parent region (ID)',
@ -77,7 +77,7 @@ class RegionFilterSet(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class SiteFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): class SiteFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -131,7 +131,7 @@ class SiteFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
class RackGroupFilterSet(NameSlugSearchFilterSet): class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
region_id = TreeNodeMultipleChoiceFilter( region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(), queryset=Region.objects.all(),
field_name='site__region__in', field_name='site__region__in',
@ -159,14 +159,14 @@ class RackGroupFilterSet(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class RackRoleFilterSet(NameSlugSearchFilterSet): class RackRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class Meta: class Meta:
model = RackRole model = RackRole
fields = ['id', 'name', 'slug', 'color'] fields = ['id', 'name', 'slug', 'color']
class RackFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): class RackFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -244,7 +244,7 @@ class RackFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter
) )
class RackReservationFilterSet(TenancyFilterSet): class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -305,14 +305,14 @@ class RackReservationFilterSet(TenancyFilterSet):
) )
class ManufacturerFilterSet(NameSlugSearchFilterSet): class ManufacturerFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class Meta: class Meta:
model = Manufacturer model = Manufacturer
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class DeviceTypeFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -402,7 +402,7 @@ class DeviceTypeFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet):
return queryset.exclude(device_bay_templates__isnull=value) return queryset.exclude(device_bay_templates__isnull=value)
class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet): class DeviceTypeComponentFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
devicetype_id = django_filters.ModelMultipleChoiceFilter( devicetype_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceType.objects.all(), queryset=DeviceType.objects.all(),
field_name='device_type_id', field_name='device_type_id',
@ -466,14 +466,14 @@ class DeviceBayTemplateFilterSet(DeviceTypeComponentFilterSet):
fields = ['id', 'name'] fields = ['id', 'name']
class DeviceRoleFilterSet(NameSlugSearchFilterSet): class DeviceRoleFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class Meta: class Meta:
model = DeviceRole model = DeviceRole
fields = ['id', 'name', 'slug', 'color', 'vm_role'] fields = ['id', 'name', 'slug', 'color', 'vm_role']
class PlatformFilterSet(NameSlugSearchFilterSet): class PlatformFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
manufacturer_id = django_filters.ModelMultipleChoiceFilter( manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer', field_name='manufacturer',
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
@ -491,7 +491,7 @@ class PlatformFilterSet(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'napalm_driver'] fields = ['id', 'name', 'slug', 'napalm_driver']
class DeviceFilterSet(LocalConfigContextFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet): class DeviceFilterSet(BaseFilterSet, LocalConfigContextFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -690,7 +690,7 @@ class DeviceFilterSet(LocalConfigContextFilterSet, TenancyFilterSet, CustomField
return queryset.exclude(device_bays__isnull=value) return queryset.exclude(device_bays__isnull=value)
class DeviceComponentFilterSet(django_filters.FilterSet): class DeviceComponentFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -1002,7 +1002,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet):
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
class VirtualChassisFilterSet(django_filters.FilterSet): class VirtualChassisFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -1056,7 +1056,7 @@ class VirtualChassisFilterSet(django_filters.FilterSet):
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
class CableFilterSet(django_filters.FilterSet): class CableFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -1119,7 +1119,7 @@ class CableFilterSet(django_filters.FilterSet):
return queryset return queryset
class ConsoleConnectionFilterSet(django_filters.FilterSet): class ConsoleConnectionFilterSet(BaseFilterSet):
site = django_filters.CharFilter( site = django_filters.CharFilter(
method='filter_site', method='filter_site',
label='Site (slug)', label='Site (slug)',
@ -1150,7 +1150,7 @@ class ConsoleConnectionFilterSet(django_filters.FilterSet):
) )
class PowerConnectionFilterSet(django_filters.FilterSet): class PowerConnectionFilterSet(BaseFilterSet):
site = django_filters.CharFilter( site = django_filters.CharFilter(
method='filter_site', method='filter_site',
label='Site (slug)', label='Site (slug)',
@ -1181,7 +1181,7 @@ class PowerConnectionFilterSet(django_filters.FilterSet):
) )
class InterfaceConnectionFilterSet(django_filters.FilterSet): class InterfaceConnectionFilterSet(BaseFilterSet):
site = django_filters.CharFilter( site = django_filters.CharFilter(
method='filter_site', method='filter_site',
label='Site (slug)', label='Site (slug)',
@ -1215,7 +1215,7 @@ class InterfaceConnectionFilterSet(django_filters.FilterSet):
) )
class PowerPanelFilterSet(django_filters.FilterSet): class PowerPanelFilterSet(BaseFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -1264,7 +1264,7 @@ class PowerPanelFilterSet(django_filters.FilterSet):
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
class PowerFeedFilterSet(CustomFieldFilterSet, CreatedUpdatedFilterSet): class PowerFeedFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'

View File

@ -4,6 +4,7 @@ from django.db.models import Q
from dcim.models import DeviceRole, Platform, Region, Site from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.filters import BaseFilterSet
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from .choices import * from .choices import *
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag
@ -89,21 +90,21 @@ class CustomFieldFilterSet(django_filters.FilterSet):
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf) self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf)
class GraphFilterSet(django_filters.FilterSet): class GraphFilterSet(BaseFilterSet):
class Meta: class Meta:
model = Graph model = Graph
fields = ['type', 'name', 'template_language'] fields = ['type', 'name', 'template_language']
class ExportTemplateFilterSet(django_filters.FilterSet): class ExportTemplateFilterSet(BaseFilterSet):
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = ['content_type', 'name', 'template_language'] fields = ['content_type', 'name', 'template_language']
class TagFilterSet(django_filters.FilterSet): class TagFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -122,7 +123,7 @@ class TagFilterSet(django_filters.FilterSet):
) )
class ConfigContextFilterSet(django_filters.FilterSet): class ConfigContextFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -234,7 +235,7 @@ class ConfigContextFilterSet(django_filters.FilterSet):
# Filter for Local Config Context Data # Filter for Local Config Context Data
# #
class LocalConfigContextFilterSet(django_filters.FilterSet): class LocalConfigContextFilterSet(BaseFilterSet):
local_context_data = django_filters.BooleanFilter( local_context_data = django_filters.BooleanFilter(
method='_local_context_data', method='_local_context_data',
label='Has local config context data', label='Has local config context data',
@ -244,7 +245,7 @@ class LocalConfigContextFilterSet(django_filters.FilterSet):
return queryset.exclude(local_context_data__isnull=value) return queryset.exclude(local_context_data__isnull=value)
class ObjectChangeFilterSet(django_filters.FilterSet): class ObjectChangeFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',

View File

@ -2,7 +2,7 @@ import django_filters
from django.db.models import Q from django.db.models import Q
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter from utilities.filters import BaseFilterSet, NameSlugSearchFilterSet, NumericInFilter, TagFilter
from .models import Tenant, TenantGroup from .models import Tenant, TenantGroup

View File

@ -27,3 +27,41 @@ COLOR_CHOICES = (
('111111', 'Black'), ('111111', 'Black'),
('ffffff', 'White'), ('ffffff', 'White'),
) )
#
# Filter lookup expressions
#
FILTER_CHAR_BASED_LOOKUP_MAP = dict(
n='exact',
ic='icontains',
nic='icontains',
iew='iendswith',
niew='iendswith',
isw='istartswith',
nisw='istartswith',
ie='iexact',
nie='iexact'
)
FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(
n='exact',
lte='lte',
lt='lt',
gte='gte',
gt='gt'
)
FILTER_LOOKUP_HELP_TEXT_MAP = dict(
icontains='case insensitive contains',
iendswith='case insensitive ends with',
istartswith='case insensitive starts with',
iexact='case insensitive exact',
exact='case sensitive exact',
lt='less than',
lte='less than or equal',
gt='greater than',
gte='greater than or equal',
n='negated'
)

View File

@ -3,7 +3,12 @@ from dcim.forms import MACAddressField
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django_filters.utils import get_model_field
from extras.models import Tag from extras.models import Tag
from utilities.constants import (
FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_LOOKUP_HELP_TEXT_MAP, FILTER_NUMERIC_BASED_LOOKUP_MAP
)
def multivalue_field_factory(field_class): def multivalue_field_factory(field_class):
@ -111,6 +116,92 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter):
# FilterSets # FilterSets
# #
class BaseFilterSet(django_filters.FilterSet):
"""
A base filterset which provides common functionaly to all NetBox filtersets
"""
@classmethod
def get_filters(cls):
"""
Override filter generation to support dynamic lookup expressions for certain filter types.
For specific filter types, new filters are created based on defined lookup expressions in
the form `<field_name>_<lookup_expr>`
"""
filters = super().get_filters()
new_filters = {}
for existing_filter_name, existing_filter in filters.items():
# Loop over existing filters to extract metadata by which to create new filters
# It the filter makes use of a custom filter method or lookup expression skip it
# as we cannot sanely handle these cases in a generic mannor
if existing_filter.method is not None or existing_filter.lookup_expr != 'exact':
continue
# Choose the lookup expression map based on the filter type
if isinstance(existing_filter, (
django_filters.filters.CharFilter,
django_filters.MultipleChoiceFilter,
MultiValueCharFilter,
MultiValueMACAddressFilter,
TagFilter
)):
lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP
elif isinstance(existing_filter, (
MultiValueDateFilter,
MultiValueDateTimeFilter,
MultiValueNumberFilter,
MultiValueTimeFilter
)):
lookup_map = FILTER_NUMERIC_BASED_LOOKUP_MAP
elif isinstance(existing_filter, (
django_filters.ModelChoiceFilter,
django_filters.ModelMultipleChoiceFilter,
NumericInFilter,
TreeNodeMultipleChoiceFilter,
)):
# These filter types support only negation
lookup_map = dict(
n='exact'
)
else:
# Do no augment any other filter types with more lookup expressions
continue
# Get properties of the existing filter for later use
field_name = existing_filter.field_name
field = get_model_field(cls._meta.model, field_name)
# Create new filters for each lookup expression in the map
for lookup_name, lookup_expr in lookup_map.items():
new_filter_name = '{}_{}'.format(existing_filter_name, lookup_name)
try:
print(existing_filter_name)
new_filter = cls.filter_for_field(field, field_name, lookup_expr)
except django_filters.exceptions.FieldLookupError:
# The filter could not be created because the lookup expression is not supported
continue
help_text = FILTER_LOOKUP_HELP_TEXT_MAP[lookup_expr]
if lookup_name.startswith('n'):
# This is a negation filter which requires a queryselt.exclud() clause
new_filter.exclude = True
help_text = 'negated {}'.format(help_text)
new_filter.extra = existing_filter.extra
new_filter.extra['help_text'] = '{} - {}'.format(field_name, help_text)
new_filters[new_filter_name] = new_filter
filters.update(new_filters)
return filters
class NameSlugSearchFilterSet(django_filters.FilterSet): class NameSlugSearchFilterSet(django_filters.FilterSet):
""" """
A base class for adding the search method to models which only expose the `name` and `slug` fields A base class for adding the search method to models which only expose the `name` and `slug` fields