Fixes #16782: Add object filtering for custom fields (#16994)

* Fixes #16782: Add object filtering for custom fields

* Add validation for related_object_filter

* Extend documentation & misc cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
samk-acw 2024-07-30 05:45:48 +10:00 committed by GitHub
parent a12fdd6e2d
commit 650898719e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 60 additions and 4 deletions

View File

@ -74,6 +74,8 @@ If a default value is specified for a selection field, it must exactly match one
An object or multi-object custom field can be used to refer to a particular NetBox object or objects as the "value" for a custom field. These custom fields must define an `object_type`, which determines the type of object to which custom field instances point. An object or multi-object custom field can be used to refer to a particular NetBox object or objects as the "value" for a custom field. These custom fields must define an `object_type`, which determines the type of object to which custom field instances point.
By default, an object choice field will make all objects of that type available for selection in the drop-down. The list choices can be filtered to show only objects with certain values by providing a `query_params` dict in the Related Object Filter field, as a JSON value. More information about `query_params` can be found [here](./custom-scripts.md#objectvar).
## Custom Fields in Templates ## Custom Fields in Templates
Several features within NetBox, such as export templates and webhooks, utilize Jinja2 templating. For convenience, objects which support custom field assignment expose custom field data through the `cf` property. This is a bit cleaner than accessing custom field data through the actual field (`custom_field_data`). Several features within NetBox, such as export templates and webhooks, utilize Jinja2 templating. For convenience, objects which support custom field assignment expose custom field data through the `cf` property. This is a bit cleaner than accessing custom field data through the actual field (`custom_field_data`).

View File

@ -42,6 +42,13 @@ The type of data this field holds. This must be one of the following:
For object and multiple-object fields only. Designates the type of NetBox object being referenced. For object and multiple-object fields only. Designates the type of NetBox object being referenced.
### Related Object Filter
For object and multi-object custom fields, a filter may be defined to limit the available objects when populating a field value. This filter maps object attributes to values. For example, `{"status": "active"}` will include only objects with a status of "active."
!!! warning
This setting is employed for convenience only, and should not be relied upon to enforce data integrity.
### Weight ### Weight
A numeric weight used to override alphabetic ordering of fields by name. Custom fields with a lower weight will be listed before those with a higher weight. (Note that weight applies within the context of a custom field group, if defined.) A numeric weight used to override alphabetic ordering of fields by name. Custom fields with a lower weight will be listed before those with a higher weight. (Note that weight applies within the context of a custom field group, if defined.)

View File

@ -62,7 +62,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'object_types', 'type', 'related_object_type', 'data_type', 'id', 'url', 'display_url', 'display', 'object_types', 'type', 'related_object_type', 'data_type',
'name', 'label', 'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible', 'name', 'label', 'group_name', 'description', 'required', 'search_weight', 'filter_logic', 'ui_visible',
'ui_editable', 'is_cloneable', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'ui_editable', 'is_cloneable', 'default', 'related_object_filter', 'weight', 'validation_minimum', 'validation_maximum',
'validation_regex', 'validation_unique', 'choice_set', 'comments', 'created', 'last_updated', 'validation_regex', 'validation_unique', 'choice_set', 'comments', 'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@ -67,7 +67,7 @@ class CustomFieldForm(forms.ModelForm):
FieldSet( FieldSet(
'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', name=_('Behavior') 'search_weight', 'filter_logic', 'ui_visible', 'ui_editable', 'weight', 'is_cloneable', name=_('Behavior')
), ),
FieldSet('default', 'choice_set', name=_('Values')), FieldSet('default', 'choice_set', 'related_object_filter', name=_('Values')),
FieldSet( FieldSet(
'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_unique', name=_('Validation') 'validation_minimum', 'validation_maximum', 'validation_regex', 'validation_unique', name=_('Validation')
), ),

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.7 on 2024-07-26 01:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0119_eventrule_event_types'),
]
operations = [
migrations.AddField(
model_name='customfield',
name='related_object_filter',
field=models.JSONField(blank=True, null=True),
),
]

View File

@ -154,6 +154,14 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
'Default value for the field (must be a JSON value). Encapsulate strings with double quotes (e.g. "Foo").' 'Default value for the field (must be a JSON value). Encapsulate strings with double quotes (e.g. "Foo").'
) )
) )
related_object_filter = models.JSONField(
blank=True,
null=True,
help_text=_(
'Filter the object selection choices using a query_params dict (must be a JSON value).'
'Encapsulate strings with double quotes (e.g. "Foo").'
)
)
weight = models.PositiveSmallIntegerField( weight = models.PositiveSmallIntegerField(
default=100, default=100,
verbose_name=_('display weight'), verbose_name=_('display weight'),
@ -373,6 +381,17 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
.format(type=self.get_type_display()) .format(type=self.get_type_display())
}) })
# Related object filter can be set only for object-type fields, and must contain a dictionary mapping (if set)
if self.related_object_filter is not None:
if self.type not in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
raise ValidationError({
'related_object_filter': _("A related object filter can be defined only for object fields.")
})
if type(self.related_object_filter) is not dict:
raise ValidationError({
'related_object_filter': _("Filter must be defined as a dictionary mapping attributes to values.")
})
def serialize(self, value): def serialize(self, value):
""" """
Prepare a value for storage as JSON data. Prepare a value for storage as JSON data.
@ -511,7 +530,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
field = field_class( field = field_class(
queryset=model.objects.all(), queryset=model.objects.all(),
required=required, required=required,
initial=initial initial=initial,
query_params=self.related_object_filter
) )
# Multiple objects # Multiple objects
@ -522,6 +542,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
queryset=model.objects.all(), queryset=model.objects.all(),
required=required, required=required,
initial=initial, initial=initial,
query_params=self.related_object_filter
) )
# Text # Text

View File

@ -23,7 +23,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests): class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = CustomField.objects.all() queryset = CustomField.objects.all()
filterset = CustomFieldFilterSet filterset = CustomFieldFilterSet
ignore_fields = ('default',) ignore_fields = ('default', 'related_object_filter')
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -52,6 +52,14 @@
<th scope="row">{% trans "Default Value" %}</th> <th scope="row">{% trans "Default Value" %}</th>
<td>{{ object.default }}</td> <td>{{ object.default }}</td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Related object filter" %}</th>
{% if object.related_object_filter %}
<td><pre>{{ object.related_object_filter|json }}</pre></td>
{% else %}
<td>{{ ''|placeholder }}</td>
{% endif %}
</tr>
</table> </table>
</div> </div>
<div class="card"> <div class="card">