diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 752a6ccaa..07b5a9ae7 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -39,7 +39,7 @@ class CustomFieldChoiceAdmin(admin.TabularInline): @admin.register(CustomField) class CustomFieldAdmin(admin.ModelAdmin): inlines = [CustomFieldChoiceAdmin] - list_display = ['name', 'models', 'type', 'required', 'default', 'weight', 'description'] + list_display = ['name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description'] form = CustomFieldForm def models(self, obj): diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index d068a3023..94f58c2d1 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -26,6 +26,16 @@ CUSTOMFIELD_TYPE_CHOICES = ( (CF_TYPE_SELECT, 'Selection'), ) +# Custom field filter logic choices +CF_FILTER_DISABLED = 0 +CF_FILTER_LOOSE = 1 +CF_FILTER_EXACT = 2 +CF_FILTER_CHOICES = ( + (CF_FILTER_DISABLED, 'Disabled'), + (CF_FILTER_LOOSE, 'Loose'), + (CF_FILTER_EXACT, 'Exact'), +) + # Graph types GRAPH_TYPE_INTERFACE = 100 GRAPH_TYPE_PROVIDER = 200 diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 5713d4af4..f21c228db 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -5,7 +5,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from dcim.models import Site -from .constants import CF_TYPE_SELECT +from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT from .models import CustomField, Graph, ExportTemplate, TopologyMap, UserAction @@ -14,8 +14,9 @@ class CustomFieldFilter(django_filters.Filter): Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name. """ - def __init__(self, cf_type, *args, **kwargs): - self.cf_type = cf_type + def __init__(self, custom_field, *args, **kwargs): + self.cf_type = custom_field.type + self.filter_logic = custom_field.filter_logic super(CustomFieldFilter, self).__init__(*args, **kwargs) def filter(self, queryset, value): @@ -41,10 +42,12 @@ class CustomFieldFilter(django_filters.Filter): except ValueError: return queryset.none() - return queryset.filter( - custom_field_values__field__name=self.name, - custom_field_values__serialized_value__icontains=value, - ) + # Apply the assigned filter logic (exact or loose) + queryset = queryset.filter(custom_field_values__field__name=self.name) + if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT: + return queryset.filter(custom_field_values__serialized_value=value) + else: + return queryset.filter(custom_field_values__serialized_value__icontains=value) class CustomFieldFilterSet(django_filters.FilterSet): @@ -56,9 +59,9 @@ class CustomFieldFilterSet(django_filters.FilterSet): super(CustomFieldFilterSet, self).__init__(*args, **kwargs) obj_type = ContentType.objects.get_for_model(self._meta.model) - custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True) + custom_fields = CustomField.objects.filter(obj_type=obj_type).exclude(filter_logic=CF_FILTER_DISABLED) for cf in custom_fields: - self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type) + self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, custom_field=cf) class GraphFilter(django_filters.FilterSet): diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 22a604dd0..a923ae596 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -6,7 +6,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType from utilities.forms import BootstrapMixin, BulkEditForm, LaxURLField -from .constants import CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL +from .constants import CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL from .models import CustomField, CustomFieldValue, ImageAttachment @@ -15,10 +15,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F Retrieve all CustomFields applicable to the given ContentType """ field_dict = OrderedDict() - kwargs = {'obj_type': content_type} + custom_fields = CustomField.objects.filter(obj_type=content_type) if filterable_only: - kwargs['is_filterable'] = True - custom_fields = CustomField.objects.filter(**kwargs) + custom_fields = custom_fields.exclude(filter_logic=CF_FILTER_DISABLED) for cf in custom_fields: field_name = 'cf_{}'.format(str(cf.name)) @@ -35,9 +34,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F (1, 'True'), (0, 'False'), ) - if initial.lower() in ['true', 'yes', '1']: + if initial is not None and initial.lower() in ['true', 'yes', '1']: initial = 1 - elif initial.lower() in ['false', 'no', '0']: + elif initial is not None and initial.lower() in ['false', 'no', '0']: initial = 0 else: initial = None diff --git a/netbox/extras/migrations/0010_customfield_filter_logic.py b/netbox/extras/migrations/0010_customfield_filter_logic.py new file mode 100644 index 000000000..e35a2f835 --- /dev/null +++ b/netbox/extras/migrations/0010_customfield_filter_logic.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.9 on 2018-02-21 19:48 +from __future__ import unicode_literals + +from django.db import migrations, models + +from extras.constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_FILTER_LOOSE, CF_TYPE_SELECT + + +def is_filterable_to_filter_logic(apps, schema_editor): + CustomField = apps.get_model('extras', 'CustomField') + CustomField.objects.filter(is_filterable=False).update(filter_logic=CF_FILTER_DISABLED) + CustomField.objects.filter(is_filterable=True).update(filter_logic=CF_FILTER_LOOSE) + # Select fields match on primary key only + CustomField.objects.filter(is_filterable=True, type=CF_TYPE_SELECT).update(filter_logic=CF_FILTER_EXACT) + + +def filter_logic_to_is_filterable(apps, schema_editor): + CustomField = apps.get_model('extras', 'CustomField') + CustomField.objects.filter(filter_logic=CF_FILTER_DISABLED).update(is_filterable=False) + CustomField.objects.exclude(filter_logic=CF_FILTER_DISABLED).update(is_filterable=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0009_topologymap_type'), + ] + + operations = [ + migrations.AddField( + model_name='customfield', + name='filter_logic', + field=models.PositiveSmallIntegerField(choices=[(0, 'Disabled'), (1, 'Loose'), (2, 'Exact')], default=1, help_text='Loose matches any instance of a given string; exact matches the entire field.'), + ), + migrations.AlterField( + model_name='customfield', + name='required', + field=models.BooleanField(default=False, help_text='If true, this field is required when creating new objects or editing an existing object.'), + ), + migrations.AlterField( + model_name='customfield', + name='weight', + field=models.PositiveSmallIntegerField(default=100, help_text='Fields with higher weights appear lower in a form.'), + ), + migrations.RunPython(is_filterable_to_filter_logic, filter_logic_to_is_filterable), + migrations.RemoveField( + model_name='customfield', + name='is_filterable', + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 678dba6de..341405016 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -55,22 +55,48 @@ class CustomFieldModel(object): @python_2_unicode_compatible class CustomField(models.Model): - obj_type = models.ManyToManyField(ContentType, related_name='custom_fields', verbose_name='Object(s)', - limit_choices_to={'model__in': CUSTOMFIELD_MODELS}, - help_text="The object(s) to which this field applies.") - type = models.PositiveSmallIntegerField(choices=CUSTOMFIELD_TYPE_CHOICES, default=CF_TYPE_TEXT) - name = models.CharField(max_length=50, unique=True) - label = models.CharField(max_length=50, blank=True, help_text="Name of the field as displayed to users (if not " - "provided, the field's name will be used)") - description = models.CharField(max_length=100, blank=True) - required = models.BooleanField(default=False, help_text="Determines whether this field is required when creating " - "new objects or editing an existing object.") - is_filterable = models.BooleanField(default=True, help_text="This field can be used to filter objects.") - default = models.CharField(max_length=100, blank=True, help_text="Default value for the field. Use \"true\" or " - "\"false\" for booleans. N/A for selection " - "fields.") - weight = models.PositiveSmallIntegerField(default=100, help_text="Fields with higher weights appear lower in a " - "form") + obj_type = models.ManyToManyField( + to=ContentType, + related_name='custom_fields', + verbose_name='Object(s)', + limit_choices_to={'model__in': CUSTOMFIELD_MODELS}, + help_text='The object(s) to which this field applies.' + ) + type = models.PositiveSmallIntegerField( + choices=CUSTOMFIELD_TYPE_CHOICES, + default=CF_TYPE_TEXT + ) + name = models.CharField( + max_length=50, + unique=True + ) + label = models.CharField( + max_length=50, + blank=True, + help_text='Name of the field as displayed to users (if not provided, the field\'s name will be used)' + ) + description = models.CharField( + max_length=100, + blank=True + ) + required = models.BooleanField( + default=False, + help_text='If true, this field is required when creating new objects or editing an existing object.' + ) + filter_logic = models.PositiveSmallIntegerField( + choices=CF_FILTER_CHOICES, + default=CF_FILTER_LOOSE, + help_text="Loose matches any instance of a given string; exact matches the entire field." + ) + default = models.CharField( + max_length=100, + blank=True, + help_text='Default value for the field. Use "true" or "false" for booleans. N/A for selection fields.' + ) + weight = models.PositiveSmallIntegerField( + default=100, + help_text='Fields with higher weights appear lower in a form.' + ) class Meta: ordering = ['weight', 'name']