#6003: Implement global search functionality for custom field values

This commit is contained in:
jeremystretch 2022-10-12 10:45:51 -04:00
parent a237b2a10b
commit 3dfec4925d
11 changed files with 85 additions and 19 deletions

View File

@ -91,8 +91,8 @@ class CustomFieldSerializer(ValidatedModelSerializer):
model = CustomField model = CustomField
fields = [ fields = [
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
'description', 'required', 'filter_logic', 'ui_visibility', 'default', 'weight', 'validation_minimum', 'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'default', 'weight',
'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated',
] ]
def get_data_type(self, obj): def get_data_type(self, obj):

View File

@ -72,8 +72,8 @@ class CustomFieldFilterSet(BaseFilterSet):
class Meta: class Meta:
model = CustomField model = CustomField
fields = [ fields = [
'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'ui_visibility', 'weight', 'id', 'content_types', 'name', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visibility',
'description', 'weight', 'description',
] ]
def search(self, queryset, name, value): def search(self, queryset, name, value):

View File

@ -46,8 +46,8 @@ class CustomFieldCSVForm(CSVModelForm):
class Meta: class Meta:
model = CustomField model = CustomField
fields = ( fields = (
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'weight', 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'search_weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
'validation_regex', 'ui_visibility', 'validation_regex', 'ui_visibility',
) )

View File

@ -41,9 +41,9 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
fieldsets = ( fieldsets = (
('Custom Field', ( ('Custom Field', (
'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description', 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description',
)), )),
('Behavior', ('filter_logic', 'ui_visibility')), ('Behavior', ('search_weight', 'filter_logic', 'ui_visibility', 'weight')),
('Values', ('default', 'choices')), ('Values', ('default', 'choices')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
) )

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.1 on 2022-10-12 13:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0079_search'),
]
operations = [
migrations.AddField(
model_name='customfield',
name='search_weight',
field=models.PositiveSmallIntegerField(default=1000),
),
]

View File

@ -16,6 +16,7 @@ from extras.choices import *
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
from netbox.search import FieldTypes
from utilities import filters from utilities import filters
from utilities.forms import ( from utilities.forms import (
CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
@ -30,6 +31,15 @@ __all__ = (
'CustomFieldManager', 'CustomFieldManager',
) )
SEARCH_TYPES = {
CustomFieldTypeChoices.TYPE_TEXT: FieldTypes.STRING,
CustomFieldTypeChoices.TYPE_LONGTEXT: FieldTypes.STRING,
CustomFieldTypeChoices.TYPE_INTEGER: FieldTypes.INTEGER,
CustomFieldTypeChoices.TYPE_DECIMAL: FieldTypes.FLOAT,
CustomFieldTypeChoices.TYPE_DATE: FieldTypes.STRING,
CustomFieldTypeChoices.TYPE_URL: FieldTypes.STRING,
}
class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
use_in_migrations = True use_in_migrations = True
@ -94,6 +104,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
help_text='If true, this field is required when creating new objects ' help_text='If true, this field is required when creating new objects '
'or editing an existing object.' 'or editing an existing object.'
) )
search_weight = models.PositiveSmallIntegerField(
default=1000,
help_text='Weighting for search. Lower values are considered more important. '
'Fields with a search weight of zero will be ignored.'
)
filter_logic = models.CharField( filter_logic = models.CharField(
max_length=50, max_length=50,
choices=CustomFieldFilterLogicChoices, choices=CustomFieldFilterLogicChoices,
@ -109,6 +124,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
) )
weight = models.PositiveSmallIntegerField( weight = models.PositiveSmallIntegerField(
default=100, default=100,
verbose_name='Display weight',
help_text='Fields with higher weights appear lower in a form.' help_text='Fields with higher weights appear lower in a form.'
) )
validation_minimum = models.IntegerField( validation_minimum = models.IntegerField(
@ -148,8 +164,9 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
objects = CustomFieldManager() objects = CustomFieldManager()
clone_fields = ( clone_fields = (
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'filter_logic', 'default', 'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'ui_visibility', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
'ui_visibility',
) )
class Meta: class Meta:
@ -167,6 +184,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
# Cache instance's original name so we can check later whether it has changed # Cache instance's original name so we can check later whether it has changed
self._name = self.name self._name = self.name
@property
def search_type(self):
return SEARCH_TYPES.get(self.type)
def populate_initial_data(self, content_types): def populate_initial_data(self, content_types):
""" """
Populate initial custom field data upon either a) the creation of a new CustomField, or Populate initial custom field data upon either a) the creation of a new CustomField, or

View File

@ -33,8 +33,8 @@ class CustomFieldTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = CustomField model = CustomField
fields = ( fields = (
'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default', 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description',
'description', 'filter_logic', 'ui_visibility', 'choices', 'created', 'last_updated', 'search_weight', 'filter_logic', 'ui_visibility', 'weight', 'choices', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')

View File

@ -292,6 +292,7 @@ class CustomFieldTest(TestCase):
cf = CustomField.objects.create( cf = CustomField.objects.create(
name='object_field', name='object_field',
type=CustomFieldTypeChoices.TYPE_OBJECT, type=CustomFieldTypeChoices.TYPE_OBJECT,
object_type=ContentType.objects.get_for_model(VLAN),
required=False required=False
) )
cf.content_types.set([self.object_type]) cf.content_types.set([self.object_type])
@ -323,6 +324,7 @@ class CustomFieldTest(TestCase):
cf = CustomField.objects.create( cf = CustomField.objects.create(
name='object_field', name='object_field',
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
object_type=ContentType.objects.get_for_model(VLAN),
required=False required=False
) )
cf.content_types.set([self.object_type]) cf.content_types.set([self.object_type])

View File

@ -32,6 +32,7 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'label': 'Field X', 'label': 'Field X',
'type': 'text', 'type': 'text',
'content_types': [site_ct.pk], 'content_types': [site_ct.pk],
'search_weight': 2000,
'filter_logic': CustomFieldFilterLogicChoices.FILTER_EXACT, 'filter_logic': CustomFieldFilterLogicChoices.FILTER_EXACT,
'default': None, 'default': None,
'weight': 200, 'weight': 200,
@ -40,11 +41,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility', 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility',
'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3},read-write', 'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},read-write',
'field5,Field 5,integer,dcim.site,,100,exact,,1,100,,read-write', 'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,read-write',
'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,,read-write', 'field6,Field 6,select,dcim.site,,100,3000,exact,"A,B,C",,,,read-write',
'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,,read-write', 'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write',
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {

View File

@ -51,6 +51,8 @@ class SearchIndex:
@classmethod @classmethod
def to_cache(cls, instance): def to_cache(cls, instance):
values = [] values = []
# Capture built-in fields
for name, weight in cls.fields: for name, weight in cls.fields:
type_ = cls.get_field_type(instance, name) type_ = cls.get_field_type(instance, name)
value = cls.get_field_value(instance, name) value = cls.get_field_value(instance, name)
@ -58,6 +60,18 @@ class SearchIndex:
ObjectFieldValue(name, type_, weight, value) ObjectFieldValue(name, type_, weight, value)
) )
# Capture custom fields
if hasattr(instance, 'custom_field_data'):
for cf, value in instance.get_custom_fields().items():
type_ = cf.search_type
value = instance.custom_field_data.get(cf.name)
weight = cf.search_weight
if not type_ or not value or not weight:
continue
values.append(
ObjectFieldValue(f'cf_{cf.name}', type_, weight, value)
)
return values return values

View File

@ -39,13 +39,23 @@
<td>{% checkmark object.required %}</td> <td>{% checkmark object.required %}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Weight</th> <th scope="row">Search Weight</th>
<td>{{ object.weight }}</td> <td>
{% if object.search_weight %}
{{ object.search_weight }}
{% else %}
<span class="text-muted">Disabled</span>
{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Filter Logic</th> <th scope="row">Filter Logic</th>
<td>{{ object.get_filter_logic_display }}</td> <td>{{ object.get_filter_logic_display }}</td>
</tr> </tr>
<tr>
<th scope="row">Display Weight</th>
<td>{{ object.weight }}</td>
</tr>
<tr> <tr>
<th scope="row">UI Visibility</th> <th scope="row">UI Visibility</th>
<td>{{ object.get_ui_visibility_display }}</td> <td>{{ object.get_ui_visibility_display }}</td>