Merge pull request #8261 from netbox-community/7006-custom-object-fields

Closes #7006: Custom object fields
This commit is contained in:
Jeremy Stretch 2022-01-06 14:09:40 -05:00 committed by GitHub
commit 453f2ab02d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 604 additions and 247 deletions

View File

@ -19,6 +19,8 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net
* JSON: Arbitrary data stored in JSON format
* Selection: A selection of one of several pre-defined custom choices
* Multiple selection: A selection field which supports the assignment of multiple values
* Object: A single NetBox object of the type defined by `object_type`
* Multiple object: One or more NetBox objects of the type defined by `object_type`
Each custom field must have a name. This should be a simple database-friendly string (e.g. `tps_report`) and may contain only alphanumeric characters and underscores. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form.
@ -41,3 +43,7 @@ NetBox supports limited custom validation for custom field values. Following are
Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible.
If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected.
### Custom Object Fields
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.

View File

@ -22,6 +22,12 @@ A new REST API endpoint has been added at `/api/ipam/vlan-groups/<pk>/available-
A new model has been introduced to represent function roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional.
#### Custom Object Fields ([#7006](https://github.com/netbox-community/netbox/issues/7006))
Two new types of custom field have been added: object and multi-object. These can be used to associate objects with other objects in NetBox. For example, you might create a custom field named `primary_site` on the tenant model so that a particular site can be associated with each tenant as its primary. The multi-object custom field type allows for the assignment of one or more objects of the same type.
Custom field object assignment is fully supported in the REST API, and functions similarly to normal foreign key relations. Nested representations are provided for each custom field object.
#### Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844))
Several new models have been added to support field-replaceable device modules, such as those within a chassis-based switch or router. Similar to devices, each module is instantiated from a user-defined module type, and can have components associated with it. These components become available to the parent device once the module has been installed within a module bay. This makes it very convenient to replicate the addition and deletion of device components as modules are installed and removed.
@ -96,6 +102,8 @@ Inventory item templates can be arranged hierarchically within a device type, an
* Removed the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields
* extras.ConfigContext
* Add `cluster_types` field
* extras.CustomField
* Added `object_type` field
* ipam.VLANGroup
* Added the `/availables-vlans/` endpoint
* Added the `min_vid` and `max_vid` fields

View File

@ -1,6 +1,7 @@
from django.contrib.contenttypes.models import ContentType
from rest_framework.fields import Field
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField
@ -44,9 +45,20 @@ class CustomFieldsDataField(Field):
return self._custom_fields
def to_representation(self, obj):
return {
cf.name: obj.get(cf.name) for cf in self._get_custom_fields()
}
# TODO: Fix circular import
from utilities.api import get_serializer_for_model
data = {}
for cf in self._get_custom_fields():
value = cf.deserialize(obj.get(cf.name))
if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested')
value = serializer(value, context=self.parent.context).data
elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested')
value = serializer(value, many=True, context=self.parent.context).data
data[cf.name] = value
return data
def to_internal_value(self, data):
# If updating an existing instance, start with existing custom_field_data

View File

@ -16,6 +16,8 @@ class CustomFieldTypeChoices(ChoiceSet):
TYPE_JSON = 'json'
TYPE_SELECT = 'select'
TYPE_MULTISELECT = 'multiselect'
TYPE_OBJECT = 'object'
TYPE_MULTIOBJECT = 'multiobject'
CHOICES = (
(TYPE_TEXT, 'Text'),
@ -27,6 +29,8 @@ class CustomFieldTypeChoices(ChoiceSet):
(TYPE_JSON, 'JSON'),
(TYPE_SELECT, 'Selection'),
(TYPE_MULTISELECT, 'Multiple selection'),
(TYPE_OBJECT, 'Object'),
(TYPE_MULTIOBJECT, 'Multiple objects'),
)

View File

@ -20,7 +20,7 @@ class CustomFieldsMixin:
Extend a Form to include custom field support.
"""
def __init__(self, *args, **kwargs):
self.custom_fields = []
self.custom_fields = {}
super().__init__(*args, **kwargs)
@ -49,7 +49,7 @@ class CustomFieldsMixin:
self.fields[field_name] = self._get_form_field(customfield)
# Annotate the field in the list of CustomField form fields
self.custom_fields.append(field_name)
self.custom_fields[field_name] = customfield
class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm):
@ -70,12 +70,15 @@ class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm):
def clean(self):
# Save custom field data on instance
for cf_name in self.custom_fields:
for cf_name, customfield in self.custom_fields.items():
key = cf_name[3:] # Strip "cf_" from field name
value = self.cleaned_data.get(cf_name)
empty_values = self.fields[cf_name].empty_values
# Convert "empty" values to null
self.instance.custom_field_data[key] = value if value not in empty_values else None
if value in self.fields[cf_name].empty_values:
self.instance.custom_field_data[key] = None
else:
self.instance.custom_field_data[key] = customfield.serialize(value)
return super().clean()

View File

@ -35,7 +35,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
model = CustomField
fields = '__all__'
fieldsets = (
('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')),
('Custom Field', ('name', 'label', 'type', 'object_type', 'weight', 'required', 'description')),
('Assigned Models', ('content_types',)),
('Behavior', ('filter_logic',)),
('Values', ('default', 'choices')),

View File

@ -0,0 +1,18 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0068_configcontext_cluster_types'),
]
operations = [
migrations.AddField(
model_name='customfield',
name='object_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'),
),
]

View File

@ -16,7 +16,8 @@ from extras.utils import FeatureQuery, extras_features
from netbox.models import ChangeLoggedModel
from utilities import filters
from utilities.forms import (
CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
CSVChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, LaxURLField,
StaticSelectMultiple, StaticSelect, add_blank_choice,
)
from utilities.querysets import RestrictedQuerySet
from utilities.validators import validate_regex
@ -50,7 +51,15 @@ class CustomField(ChangeLoggedModel):
type = models.CharField(
max_length=50,
choices=CustomFieldTypeChoices,
default=CustomFieldTypeChoices.TYPE_TEXT
default=CustomFieldTypeChoices.TYPE_TEXT,
help_text='The type of data this custom field holds'
)
object_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
blank=True,
null=True,
help_text='The type of NetBox object this field maps to (for object fields)'
)
name = models.CharField(
max_length=50,
@ -122,7 +131,6 @@ class CustomField(ChangeLoggedModel):
null=True,
help_text='Comma-separated list of available choices (for selection fields)'
)
objects = CustomFieldManager()
class Meta:
@ -234,6 +242,43 @@ class CustomField(ChangeLoggedModel):
'default': f"The specified default value ({self.default}) is not listed as an available choice."
})
# Object fields must define an object_type; other fields must not
if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
if not self.object_type:
raise ValidationError({
'object_type': "Object fields must define an object type."
})
elif self.object_type:
raise ValidationError({
'object_type': f"{self.get_type_display()} fields may not define an object type."
})
def serialize(self, value):
"""
Prepare a value for storage as JSON data.
"""
if value is None:
return value
if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
return value.pk
if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
return [obj.pk for obj in value] or None
return value
def deserialize(self, value):
"""
Convert JSON data to a Python object suitable for the field type.
"""
if value is None:
return value
if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
model = self.object_type.model_class()
return model.objects.filter(pk=value).first()
if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
model = self.object_type.model_class()
return model.objects.filter(pk__in=value)
return value
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
"""
Return a form field suitable for setting a CustomField's value for an object.
@ -300,6 +345,24 @@ class CustomField(ChangeLoggedModel):
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
field = forms.JSONField(required=required, initial=initial)
# Object
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
model = self.object_type.model_class()
field = DynamicModelChoiceField(
queryset=model.objects.all(),
required=required,
initial=initial
)
# Multiple objects
elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
model = self.object_type.model_class()
field = DynamicModelMultipleChoiceField(
queryset=model.objects.all(),
required=required,
initial=initial
)
# Text
else:
if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT:

View File

@ -8,13 +8,15 @@ from dcim.forms import SiteCSVForm
from dcim.models import Site, Rack
from extras.choices import *
from extras.models import CustomField
from ipam.models import VLAN
from utilities.testing import APITestCase, TestCase
from virtualization.models import VirtualMachine
class CustomFieldTest(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
Site.objects.bulk_create([
Site(name='Site A', slug='site-a'),
@ -22,137 +24,294 @@ class CustomFieldTest(TestCase):
Site(name='Site C', slug='site-c'),
])
def test_simple_fields(self):
DATA = (
{
'field': {
'type': CustomFieldTypeChoices.TYPE_TEXT,
},
'value': 'Foobar!',
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_LONGTEXT,
},
'value': 'Text with **Markdown**',
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_INTEGER,
},
'value': 0,
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_INTEGER,
'validation_minimum': 1,
'validation_maximum': 100,
},
'value': 42,
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_INTEGER,
'validation_minimum': -100,
'validation_maximum': -1,
},
'value': -42,
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_BOOLEAN,
},
'value': True,
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_BOOLEAN,
},
'value': False,
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_DATE,
},
'value': '2016-06-23',
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_URL,
},
'value': 'http://example.com/',
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_JSON,
},
'value': '{"foo": 1, "bar": 2}',
},
cls.object_type = ContentType.objects.get_for_model(Site)
def test_text_field(self):
value = 'Foobar!'
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='text_field',
type=CustomFieldTypeChoices.TYPE_TEXT,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
obj_type = ContentType.objects.get_for_model(Site)
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
for data in DATA:
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
# Create a custom field
cf = CustomField(name='my_field', required=False, **data['field'])
cf.save()
cf.content_types.set([obj_type])
def test_longtext_field(self):
value = 'A' * 256
# Check that the field has a null initial value
site = Site.objects.first()
self.assertIsNone(site.custom_field_data[cf.name])
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='longtext_field',
type=CustomFieldTypeChoices.TYPE_LONGTEXT,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Assign a value to the first Site
site.custom_field_data[cf.name] = data['value']
site.save()
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Retrieve the stored value
site.refresh_from_db()
self.assertEqual(site.custom_field_data[cf.name], data['value'])
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
# Delete the stored value
site.custom_field_data.pop(cf.name)
site.save()
site.refresh_from_db()
self.assertIsNone(site.custom_field_data.get(cf.name))
def test_integer_field(self):
# Delete the custom field
cf.delete()
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='integer_field',
type=CustomFieldTypeChoices.TYPE_INTEGER,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
for value in (123456, 0, -123456):
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_boolean_field(self):
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='boolean_field',
type=CustomFieldTypeChoices.TYPE_INTEGER,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
for value in (True, False):
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_date_field(self):
value = '2016-06-23'
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='date_field',
type=CustomFieldTypeChoices.TYPE_TEXT,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_url_field(self):
value = 'http://example.com/'
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='url_field',
type=CustomFieldTypeChoices.TYPE_URL,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_json_field(self):
value = '{"foo": 1, "bar": 2}'
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='json_field',
type=CustomFieldTypeChoices.TYPE_JSON,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_select_field(self):
obj_type = ContentType.objects.get_for_model(Site)
CHOICES = ('Option A', 'Option B', 'Option C')
value = CHOICES[1]
# Create a custom field
cf = CustomField(
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='select_field',
type=CustomFieldTypeChoices.TYPE_SELECT,
name='my_field',
required=False,
choices=['Option A', 'Option B', 'Option C']
choices=CHOICES
)
cf.save()
cf.content_types.set([obj_type])
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Check that the field has a null initial value
site = Site.objects.first()
self.assertIsNone(site.custom_field_data[cf.name])
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Assign a value to the first Site
site.custom_field_data[cf.name] = 'Option A'
site.save()
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
# Retrieve the stored value
site.refresh_from_db()
self.assertEqual(site.custom_field_data[cf.name], 'Option A')
def test_multiselect_field(self):
CHOICES = ['Option A', 'Option B', 'Option C']
value = [CHOICES[1], CHOICES[2]]
# Delete the stored value
site.custom_field_data.pop(cf.name)
site.save()
site.refresh_from_db()
self.assertIsNone(site.custom_field_data.get(cf.name))
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='multiselect_field',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
required=False,
choices=CHOICES
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Delete the custom field
cf.delete()
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_object_field(self):
value = VLAN.objects.create(name='VLAN 1', vid=1).pk
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='object_field',
type=CustomFieldTypeChoices.TYPE_OBJECT,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_multiobject_field(self):
vlans = (
VLAN(name='VLAN 1', vid=1),
VLAN(name='VLAN 2', vid=2),
VLAN(name='VLAN 3', vid=3),
)
VLAN.objects.bulk_create(vlans)
value = [vlan.pk for vlan in vlans]
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='object_field',
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_rename_customfield(self):
obj_type = ContentType.objects.get_for_model(Site)
@ -201,76 +360,77 @@ class CustomFieldAPITest(APITestCase):
def setUpTestData(cls):
content_type = ContentType.objects.get_for_model(Site)
# Text custom field
cls.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo')
cls.cf_text.save()
cls.cf_text.content_types.set([content_type])
# Create some VLANs
vlans = (
VLAN(name='VLAN 1', vid=1),
VLAN(name='VLAN 2', vid=2),
VLAN(name='VLAN 3', vid=3),
VLAN(name='VLAN 4', vid=4),
VLAN(name='VLAN 5', vid=5),
)
VLAN.objects.bulk_create(vlans)
# Long text custom field
cls.cf_longtext = CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC')
cls.cf_longtext.save()
cls.cf_longtext.content_types.set([content_type])
custom_fields = (
CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'),
CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123),
CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False),
CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'),
CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'),
CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'),
CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', default='Foo', choices=(
'Foo', 'Bar', 'Baz'
)),
CustomField(
type=CustomFieldTypeChoices.TYPE_OBJECT,
name='object_field',
object_type=ContentType.objects.get_for_model(VLAN),
default=vlans[0].pk,
),
CustomField(
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
name='multiobject_field',
object_type=ContentType.objects.get_for_model(VLAN),
default=[vlans[0].pk, vlans[1].pk],
),
)
for cf in custom_fields:
cf.save()
cf.content_types.set([content_type])
# Integer custom field
cls.cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123)
cls.cf_integer.save()
cls.cf_integer.content_types.set([content_type])
# Boolean custom field
cls.cf_boolean = CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False)
cls.cf_boolean.save()
cls.cf_boolean.content_types.set([content_type])
# Date custom field
cls.cf_date = CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01')
cls.cf_date.save()
cls.cf_date.content_types.set([content_type])
# URL custom field
cls.cf_url = CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1')
cls.cf_url.save()
cls.cf_url.content_types.set([content_type])
# JSON custom field
cls.cf_json = CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}')
cls.cf_json.save()
cls.cf_json.content_types.set([content_type])
# Select custom field
cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', choices=['Foo', 'Bar', 'Baz'])
cls.cf_select.default = 'Foo'
cls.cf_select.save()
cls.cf_select.content_types.set([content_type])
# Create some sites
cls.sites = (
# Create some sites *after* creating the custom fields. This ensures that
# default values are not set for the assigned objects.
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(cls.sites)
Site.objects.bulk_create(sites)
# Assign custom field values for site 2
cls.sites[1].custom_field_data = {
cls.cf_text.name: 'bar',
cls.cf_longtext.name: 'DEF',
cls.cf_integer.name: 456,
cls.cf_boolean.name: True,
cls.cf_date.name: '2020-01-02',
cls.cf_url.name: 'http://example.com/2',
cls.cf_json.name: '{"foo": 1, "bar": 2}',
cls.cf_select.name: 'Bar',
sites[1].custom_field_data = {
custom_fields[0].name: 'bar',
custom_fields[1].name: 'DEF',
custom_fields[2].name: 456,
custom_fields[3].name: True,
custom_fields[4].name: '2020-01-02',
custom_fields[5].name: 'http://example.com/2',
custom_fields[6].name: '{"foo": 1, "bar": 2}',
custom_fields[7].name: 'Bar',
custom_fields[8].name: vlans[1].pk,
custom_fields[9].name: [vlans[2].pk, vlans[3].pk],
}
cls.sites[1].save()
sites[1].save()
def test_get_single_object_without_custom_field_data(self):
"""
Validate that custom fields are present on an object even if it has no values defined.
"""
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[0].pk})
site1 = Site.objects.get(name='Site 1')
url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
self.add_permissions('dcim.view_site')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.sites[0].name)
self.assertEqual(response.data['name'], site1.name)
self.assertEqual(response.data['custom_fields'], {
'text_field': None,
'longtext_field': None,
@ -280,18 +440,21 @@ class CustomFieldAPITest(APITestCase):
'url_field': None,
'json_field': None,
'choice_field': None,
'object_field': None,
'multiobject_field': None,
})
def test_get_single_object_with_custom_field_data(self):
"""
Validate that custom fields are present and correctly set for an object with values defined.
"""
site2_cfvs = self.sites[1].custom_field_data
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
site2 = Site.objects.get(name='Site 2')
site2_cfvs = site2.custom_field_data
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
self.add_permissions('dcim.view_site')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.sites[1].name)
self.assertEqual(response.data['name'], site2.name)
self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field'])
self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field'])
self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field'])
@ -300,11 +463,19 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field'])
self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field'])
self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field'])
self.assertEqual(
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
site2_cfvs['multiobject_field']
)
def test_create_single_object_with_defaults(self):
"""
Create a new site with no specified custom field values and check that it received the default values.
"""
cf_defaults = {
cf.name: cf.default for cf in CustomField.objects.all()
}
data = {
'name': 'Site 3',
'slug': 'site-3',
@ -317,25 +488,32 @@ class CustomFieldAPITest(APITestCase):
# Validate response data
response_cf = response.data['custom_fields']
self.assertEqual(response_cf['text_field'], self.cf_text.default)
self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default)
self.assertEqual(response_cf['number_field'], self.cf_integer.default)
self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default)
self.assertEqual(response_cf['date_field'], self.cf_date.default)
self.assertEqual(response_cf['url_field'], self.cf_url.default)
self.assertEqual(response_cf['json_field'], self.cf_json.default)
self.assertEqual(response_cf['choice_field'], self.cf_select.default)
self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(response_cf['number_field'], cf_defaults['number_field'])
self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field'])
self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field'])
self.assertEqual(
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
cf_defaults['multiobject_field']
)
# Validate database data
site = Site.objects.get(pk=response.data['id'])
self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default)
self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default)
self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default)
self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default)
self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default)
self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default)
self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default)
self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default)
self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field'])
self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field'])
self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field'])
self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field'])
def test_create_single_object_with_values(self):
"""
@ -353,6 +531,8 @@ class CustomFieldAPITest(APITestCase):
'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 2}',
'choice_field': 'Bar',
'object_field': VLAN.objects.get(vid=2).pk,
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
},
}
url = reverse('dcim-api:site-list')
@ -372,6 +552,11 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response_cf['url_field'], data_cf['url_field'])
self.assertEqual(response_cf['json_field'], data_cf['json_field'])
self.assertEqual(response_cf['choice_field'], data_cf['choice_field'])
self.assertEqual(response_cf['object_field']['id'], data_cf['object_field'])
self.assertEqual(
[obj['id'] for obj in response_cf['multiobject_field']],
data_cf['multiobject_field']
)
# Validate database data
site = Site.objects.get(pk=response.data['id'])
@ -383,12 +568,17 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field'])
self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field'])
self.assertEqual(site.custom_field_data['object_field'], data_cf['object_field'])
self.assertEqual(site.custom_field_data['multiobject_field'], data_cf['multiobject_field'])
def test_create_multiple_objects_with_defaults(self):
"""
Create three news sites with no specified custom field values and check that each received
Create three new sites with no specified custom field values and check that each received
the default custom field values.
"""
cf_defaults = {
cf.name: cf.default for cf in CustomField.objects.all()
}
data = (
{
'name': 'Site 3',
@ -414,25 +604,32 @@ class CustomFieldAPITest(APITestCase):
# Validate response data
response_cf = response.data[i]['custom_fields']
self.assertEqual(response_cf['text_field'], self.cf_text.default)
self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default)
self.assertEqual(response_cf['number_field'], self.cf_integer.default)
self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default)
self.assertEqual(response_cf['date_field'], self.cf_date.default)
self.assertEqual(response_cf['url_field'], self.cf_url.default)
self.assertEqual(response_cf['json_field'], self.cf_json.default)
self.assertEqual(response_cf['choice_field'], self.cf_select.default)
self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(response_cf['number_field'], cf_defaults['number_field'])
self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field'])
self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field'])
self.assertEqual(
[obj['id'] for obj in response_cf['multiobject_field']],
cf_defaults['multiobject_field']
)
# Validate database data
site = Site.objects.get(pk=response.data[i]['id'])
self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default)
self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default)
self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default)
self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default)
self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default)
self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default)
self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default)
self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default)
self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field'])
self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field'])
self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field'])
self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field'])
def test_create_multiple_objects_with_values(self):
"""
@ -447,6 +644,8 @@ class CustomFieldAPITest(APITestCase):
'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 2}',
'choice_field': 'Bar',
'object_field': VLAN.objects.get(vid=2).pk,
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
}
data = (
{
@ -484,6 +683,10 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response_cf['url_field'], custom_field_data['url_field'])
self.assertEqual(response_cf['json_field'], custom_field_data['json_field'])
self.assertEqual(response_cf['choice_field'], custom_field_data['choice_field'])
self.assertEqual(
[obj['id'] for obj in response_cf['multiobject_field']],
custom_field_data['multiobject_field']
)
# Validate database data
site = Site.objects.get(pk=response.data[i]['id'])
@ -495,21 +698,22 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field'])
self.assertEqual(site.custom_field_data['json_field'], custom_field_data['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], custom_field_data['choice_field'])
self.assertEqual(site.custom_field_data['multiobject_field'], custom_field_data['multiobject_field'])
def test_update_single_object_with_values(self):
"""
Update an object with existing custom field values. Ensure that only the updated custom field values are
modified.
"""
site = self.sites[1]
original_cfvs = {**site.custom_field_data}
site2 = Site.objects.get(name='Site 2')
original_cfvs = {**site2.custom_field_data}
data = {
'custom_fields': {
'text_field': 'ABCD',
'number_field': 1234,
},
}
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
self.add_permissions('dcim.change_site')
response = self.client.patch(url, data, format='json', **self.header)
@ -525,25 +729,32 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
self.assertEqual(response_cf['json_field'], original_cfvs['json_field'])
self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field'])
self.assertEqual(
[obj['id'] for obj in response_cf['multiobject_field']],
original_cfvs['multiobject_field']
)
# Validate database data
site.refresh_from_db()
self.assertEqual(site.custom_field_data['text_field'], data['custom_fields']['text_field'])
self.assertEqual(site.custom_field_data['number_field'], data['custom_fields']['number_field'])
self.assertEqual(site.custom_field_data['longtext_field'], original_cfvs['longtext_field'])
self.assertEqual(site.custom_field_data['boolean_field'], original_cfvs['boolean_field'])
self.assertEqual(site.custom_field_data['date_field'], original_cfvs['date_field'])
self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field'])
self.assertEqual(site.custom_field_data['json_field'], original_cfvs['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_field'])
site2.refresh_from_db()
self.assertEqual(site2.custom_field_data['text_field'], data['custom_fields']['text_field'])
self.assertEqual(site2.custom_field_data['number_field'], data['custom_fields']['number_field'])
self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field'])
self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field'])
self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field'])
self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field'])
self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field'])
self.assertEqual(site2.custom_field_data['choice_field'], original_cfvs['choice_field'])
self.assertEqual(site2.custom_field_data['multiobject_field'], original_cfvs['multiobject_field'])
def test_minimum_maximum_values_validation(self):
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
site2 = Site.objects.get(name='Site 2')
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
self.add_permissions('dcim.change_site')
self.cf_integer.validation_minimum = 10
self.cf_integer.validation_maximum = 20
self.cf_integer.save()
cf_integer = CustomField.objects.get(name='number_field')
cf_integer.validation_minimum = 10
cf_integer.validation_maximum = 20
cf_integer.save()
data = {'custom_fields': {'number_field': 9}}
response = self.client.patch(url, data, format='json', **self.header)
@ -558,11 +769,13 @@ class CustomFieldAPITest(APITestCase):
self.assertHttpStatus(response, status.HTTP_200_OK)
def test_regex_validation(self):
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
site2 = Site.objects.get(name='Site 2')
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
self.add_permissions('dcim.change_site')
self.cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters
self.cf_text.save()
cf_text = CustomField.objects.get(name='text_field')
cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters
cf_text.save()
data = {'custom_fields': {'text_field': 'ABC123'}}
response = self.client.patch(url, data, format='json', **self.header)

View File

@ -38,10 +38,27 @@ class CustomFieldModelFormTest(TestCase):
cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES)
cf_select.content_types.set([obj_type])
cf_multiselect = CustomField.objects.create(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choices=CHOICES)
cf_multiselect = CustomField.objects.create(
name='multiselect',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choices=CHOICES
)
cf_multiselect.content_types.set([obj_type])
cf_object = CustomField.objects.create(
name='object',
type=CustomFieldTypeChoices.TYPE_OBJECT,
object_type=ContentType.objects.get_for_model(Site)
)
cf_object.content_types.set([obj_type])
cf_multiobject = CustomField.objects.create(
name='multiobject',
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
object_type=ContentType.objects.get_for_model(Site)
)
cf_multiobject.content_types.set([obj_type])
def test_empty_values(self):
"""
Test that empty custom field values are stored as null

View File

@ -1,5 +1,4 @@
import logging
from collections import OrderedDict
from django.contrib.contenttypes.fields import GenericRelation
from django.core.serializers.json import DjangoJSONEncoder
@ -99,16 +98,20 @@ class CustomFieldsMixin(models.Model):
"""
from extras.models import CustomField
fields = CustomField.objects.get_for_model(self)
return OrderedDict([
(field, self.custom_field_data.get(field.name)) for field in fields
])
data = {}
for field in CustomField.objects.get_for_model(self):
value = self.custom_field_data.get(field.name)
data[field] = field.deserialize(value)
return data
def clean(self):
super().clean()
from extras.models import CustomField
custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)}
custom_fields = {
cf.name: cf for cf in CustomField.objects.get_for_model(self)
}
# Validate all field values
for field_name, value in self.custom_field_data.items():

View File

@ -285,7 +285,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
return get_permission_for_model(self.queryset.model, 'change')
def _update_objects(self, form, request):
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
custom_fields = getattr(form, 'custom_fields', [])
standard_fields = [
field for field in form.fields if field not in custom_fields + ['pk']
]
@ -327,7 +327,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
if name in form.nullable_fields and name in nullified_fields:
obj.custom_field_data[name] = None
elif name in form.changed_data:
obj.custom_field_data[name] = form.cleaned_data[name]
obj.custom_field_data[name] = form.fields[name].prepare_value(form.cleaned_data[name])
obj.full_clean()
obj.save()

View File

@ -3,14 +3,14 @@
{% with custom_fields=object.get_custom_fields %}
{% if custom_fields %}
<div class="card">
<h5 class="card-header">
Custom Fields
</h5>
<h5 class="card-header">Custom Fields</h5>
<div class="card-body">
<table class="table table-hover attr-table">
{% for field, value in custom_fields.items %}
<tr>
<td><span title="{{ field.description|escape }}">{{ field }}</span></td>
<td>
<span title="{{ field.description|escape }}">{{ field }}</span>
</td>
<td>
{% if field.type == 'longtext' and value %}
{{ value|render_markdown }}
@ -24,6 +24,16 @@
<pre>{{ value|render_json }}</pre>
{% elif field.type == 'multiselect' and value %}
{{ value|join:", " }}
{% elif field.type == 'object' and value %}
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
{% elif field.type == 'multiobject' and value %}
{% if value %}
<ul>
{% for obj in value %}
<li><a href="{{ obj.get_absolute_url }}">{{ obj }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% elif value is not None %}
{{ value }}
{% elif field.required %}