Browse Source

Closes #1693: Allow specifying loose or exact matching for custom field filters

Jeremy Stretch 7 years ago
parent
commit
a5dc9537e5

+ 1 - 1
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):

+ 10 - 0
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

+ 12 - 9
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):

+ 5 - 6
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

+ 51 - 0
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',
+        ),
+    ]

+ 42 - 16
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']