1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Initial work on #7006

This commit is contained in:
jeremystretch
2021-12-30 17:03:41 -05:00
parent 0978777eec
commit fa1e28e860
10 changed files with 224 additions and 129 deletions

View File

@ -1,6 +1,7 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from rest_framework.fields import Field from rest_framework.fields import Field
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField from extras.models import CustomField
@ -44,9 +45,17 @@ class CustomFieldsDataField(Field):
return self._custom_fields return self._custom_fields
def to_representation(self, obj): def to_representation(self, obj):
return { # TODO: Fix circular import
cf.name: obj.get(cf.name) for cf in self._get_custom_fields() 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
data[cf.name] = value
return data
def to_internal_value(self, data): def to_internal_value(self, data):
# If updating an existing instance, start with existing custom_field_data # If updating an existing instance, start with existing custom_field_data

View File

@ -16,6 +16,7 @@ class CustomFieldTypeChoices(ChoiceSet):
TYPE_JSON = 'json' TYPE_JSON = 'json'
TYPE_SELECT = 'select' TYPE_SELECT = 'select'
TYPE_MULTISELECT = 'multiselect' TYPE_MULTISELECT = 'multiselect'
TYPE_OBJECT = 'object'
CHOICES = ( CHOICES = (
(TYPE_TEXT, 'Text'), (TYPE_TEXT, 'Text'),
@ -27,6 +28,7 @@ class CustomFieldTypeChoices(ChoiceSet):
(TYPE_JSON, 'JSON'), (TYPE_JSON, 'JSON'),
(TYPE_SELECT, 'Selection'), (TYPE_SELECT, 'Selection'),
(TYPE_MULTISELECT, 'Multiple selection'), (TYPE_MULTISELECT, 'Multiple selection'),
(TYPE_OBJECT, 'NetBox object'),
) )

View File

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

View File

@ -35,7 +35,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
model = CustomField model = CustomField
fields = '__all__' fields = '__all__'
fieldsets = ( fieldsets = (
('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')), ('Custom Field', ('name', 'label', 'type', 'object_type', 'weight', 'required', 'description')),
('Assigned Models', ('content_types',)), ('Assigned Models', ('content_types',)),
('Behavior', ('filter_logic',)), ('Behavior', ('filter_logic',)),
('Values', ('default', 'choices')), ('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', '0067_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 netbox.models import ChangeLoggedModel
from utilities import filters from utilities import filters
from utilities.forms import ( from utilities.forms import (
CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice, CSVChoiceField, DatePicker, DynamicModelChoiceField, LaxURLField, StaticSelectMultiple, StaticSelect,
add_blank_choice,
) )
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.validators import validate_regex from utilities.validators import validate_regex
@ -50,8 +51,17 @@ class CustomField(ChangeLoggedModel):
type = models.CharField( type = models.CharField(
max_length=50, max_length=50,
choices=CustomFieldTypeChoices, 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( name = models.CharField(
max_length=50, max_length=50,
unique=True, unique=True,
@ -122,7 +132,6 @@ class CustomField(ChangeLoggedModel):
null=True, null=True,
help_text='Comma-separated list of available choices (for selection fields)' help_text='Comma-separated list of available choices (for selection fields)'
) )
objects = CustomFieldManager() objects = CustomFieldManager()
class Meta: class Meta:
@ -234,6 +243,23 @@ class CustomField(ChangeLoggedModel):
'default': f"The specified default value ({self.default}) is not listed as an available choice." 'default': f"The specified default value ({self.default}) is not listed as an available choice."
}) })
def serialize(self, value):
"""
Prepare a value for storage as JSON data.
"""
if self.type == CustomFieldTypeChoices.TYPE_OBJECT and value is not None:
return value.pk
return value
def deserialize(self, value):
"""
Convert JSON data to a Python object suitable for the field type.
"""
if self.type == CustomFieldTypeChoices.TYPE_OBJECT and value is not None:
model = self.object_type.model_class()
return model.objects.filter(pk=value).first()
return value
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False): 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. Return a form field suitable for setting a CustomField's value for an object.
@ -300,6 +326,15 @@ class CustomField(ChangeLoggedModel):
elif self.type == CustomFieldTypeChoices.TYPE_JSON: elif self.type == CustomFieldTypeChoices.TYPE_JSON:
field = forms.JSONField(required=required, initial=initial) 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
)
# Text # Text
else: else:
if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT: if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT:

View File

@ -8,6 +8,7 @@ from dcim.forms import SiteCSVForm
from dcim.models import Site, Rack from dcim.models import Site, Rack
from extras.choices import * from extras.choices import *
from extras.models import CustomField from extras.models import CustomField
from ipam.models import VLAN
from utilities.testing import APITestCase, TestCase from utilities.testing import APITestCase, TestCase
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
@ -201,76 +202,67 @@ class CustomFieldAPITest(APITestCase):
def setUpTestData(cls): def setUpTestData(cls):
content_type = ContentType.objects.get_for_model(Site) content_type = ContentType.objects.get_for_model(Site)
# Text custom field # Create some VLANs
cls.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo') vlans = (
cls.cf_text.save() VLAN(name='VLAN 1', vid=1),
cls.cf_text.content_types.set([content_type]) VLAN(name='VLAN 2', vid=2),
)
VLAN.objects.bulk_create(vlans)
# Long text custom field custom_fields = (
cls.cf_longtext = CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC') CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
cls.cf_longtext.save() CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'),
cls.cf_longtext.content_types.set([content_type]) 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,
),
)
for cf in custom_fields:
cf.save()
cf.content_types.set([content_type])
# Integer custom field # Create some sites *after* creating the custom fields. This ensures that
cls.cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123) # default values are not set for the assigned objects.
cls.cf_integer.save() sites = (
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 = (
Site(name='Site 1', slug='site-1'), Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'), 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 # Assign custom field values for site 2
cls.sites[1].custom_field_data = { sites[1].custom_field_data = {
cls.cf_text.name: 'bar', custom_fields[0].name: 'bar',
cls.cf_longtext.name: 'DEF', custom_fields[1].name: 'DEF',
cls.cf_integer.name: 456, custom_fields[2].name: 456,
cls.cf_boolean.name: True, custom_fields[3].name: True,
cls.cf_date.name: '2020-01-02', custom_fields[4].name: '2020-01-02',
cls.cf_url.name: 'http://example.com/2', custom_fields[5].name: 'http://example.com/2',
cls.cf_json.name: '{"foo": 1, "bar": 2}', custom_fields[6].name: '{"foo": 1, "bar": 2}',
cls.cf_select.name: 'Bar', custom_fields[7].name: 'Bar',
custom_fields[8].name: vlans[1].pk,
} }
cls.sites[1].save() sites[1].save()
def test_get_single_object_without_custom_field_data(self): 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. 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') self.add_permissions('dcim.view_site')
response = self.client.get(url, **self.header) 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'], { self.assertEqual(response.data['custom_fields'], {
'text_field': None, 'text_field': None,
'longtext_field': None, 'longtext_field': None,
@ -280,18 +272,20 @@ class CustomFieldAPITest(APITestCase):
'url_field': None, 'url_field': None,
'json_field': None, 'json_field': None,
'choice_field': None, 'choice_field': None,
'object_field': None,
}) })
def test_get_single_object_with_custom_field_data(self): 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. Validate that custom fields are present and correctly set for an object with values defined.
""" """
site2_cfvs = self.sites[1].custom_field_data site2 = Site.objects.get(name='Site 2')
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) site2_cfvs = site2.custom_field_data
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
self.add_permissions('dcim.view_site') self.add_permissions('dcim.view_site')
response = self.client.get(url, **self.header) 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']['text_field'], site2_cfvs['text_field'])
self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_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']) self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field'])
@ -300,11 +294,15 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field']) 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']['json_field'], site2_cfvs['json_field'])
self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_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'])
def test_create_single_object_with_defaults(self): 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. 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 = { data = {
'name': 'Site 3', 'name': 'Site 3',
'slug': 'site-3', 'slug': 'site-3',
@ -317,25 +315,27 @@ class CustomFieldAPITest(APITestCase):
# Validate response data # Validate response data
response_cf = response.data['custom_fields'] response_cf = response.data['custom_fields']
self.assertEqual(response_cf['text_field'], self.cf_text.default) self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default) self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(response_cf['number_field'], self.cf_integer.default) self.assertEqual(response_cf['number_field'], cf_defaults['number_field'])
self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(response_cf['date_field'], self.cf_date.default) self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
self.assertEqual(response_cf['url_field'], self.cf_url.default) self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
self.assertEqual(response_cf['json_field'], self.cf_json.default) self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
self.assertEqual(response_cf['choice_field'], self.cf_select.default) self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field'])
self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field'])
# Validate database data # Validate database data
site = Site.objects.get(pk=response.data['id']) 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['text_field'], cf_defaults['text_field'])
self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default) self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field'])
self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default) self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default) self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field'])
self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field'])
def test_create_single_object_with_values(self): def test_create_single_object_with_values(self):
""" """
@ -353,6 +353,7 @@ class CustomFieldAPITest(APITestCase):
'url_field': 'http://example.com/2', 'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 2}', 'json_field': '{"foo": 1, "bar": 2}',
'choice_field': 'Bar', 'choice_field': 'Bar',
'object_field': VLAN.objects.get(vid=2).pk,
}, },
} }
url = reverse('dcim-api:site-list') url = reverse('dcim-api:site-list')
@ -372,6 +373,7 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response_cf['url_field'], data_cf['url_field']) self.assertEqual(response_cf['url_field'], data_cf['url_field'])
self.assertEqual(response_cf['json_field'], data_cf['json_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['choice_field'], data_cf['choice_field'])
self.assertEqual(response_cf['object_field']['id'], data_cf['object_field'])
# Validate database data # Validate database data
site = Site.objects.get(pk=response.data['id']) site = Site.objects.get(pk=response.data['id'])
@ -383,12 +385,16 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field']) 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['json_field'], data_cf['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_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'])
def test_create_multiple_objects_with_defaults(self): 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. the default custom field values.
""" """
cf_defaults = {
cf.name: cf.default for cf in CustomField.objects.all()
}
data = ( data = (
{ {
'name': 'Site 3', 'name': 'Site 3',
@ -414,25 +420,27 @@ class CustomFieldAPITest(APITestCase):
# Validate response data # Validate response data
response_cf = response.data[i]['custom_fields'] response_cf = response.data[i]['custom_fields']
self.assertEqual(response_cf['text_field'], self.cf_text.default) self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default) self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(response_cf['number_field'], self.cf_integer.default) self.assertEqual(response_cf['number_field'], cf_defaults['number_field'])
self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(response_cf['date_field'], self.cf_date.default) self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
self.assertEqual(response_cf['url_field'], self.cf_url.default) self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
self.assertEqual(response_cf['json_field'], self.cf_json.default) self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
self.assertEqual(response_cf['choice_field'], self.cf_select.default) self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field'])
self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field'])
# Validate database data # Validate database data
site = Site.objects.get(pk=response.data[i]['id']) 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['text_field'], cf_defaults['text_field'])
self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default) self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field'])
self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default) self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default) self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field'])
self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field'])
def test_create_multiple_objects_with_values(self): def test_create_multiple_objects_with_values(self):
""" """
@ -447,6 +455,7 @@ class CustomFieldAPITest(APITestCase):
'url_field': 'http://example.com/2', 'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 2}', 'json_field': '{"foo": 1, "bar": 2}',
'choice_field': 'Bar', 'choice_field': 'Bar',
'object_field': VLAN.objects.get(vid=2).pk,
} }
data = ( data = (
{ {
@ -501,15 +510,15 @@ class CustomFieldAPITest(APITestCase):
Update an object with existing custom field values. Ensure that only the updated custom field values are Update an object with existing custom field values. Ensure that only the updated custom field values are
modified. modified.
""" """
site = self.sites[1] site2 = Site.objects.get(name='Site 2')
original_cfvs = {**site.custom_field_data} original_cfvs = {**site2.custom_field_data}
data = { data = {
'custom_fields': { 'custom_fields': {
'text_field': 'ABCD', 'text_field': 'ABCD',
'number_field': 1234, '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') self.add_permissions('dcim.change_site')
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
@ -527,23 +536,25 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field']) self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field'])
# Validate database data # Validate database data
site.refresh_from_db() site2.refresh_from_db()
self.assertEqual(site.custom_field_data['text_field'], data['custom_fields']['text_field']) self.assertEqual(site2.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(site2.custom_field_data['number_field'], data['custom_fields']['number_field'])
self.assertEqual(site.custom_field_data['longtext_field'], original_cfvs['longtext_field']) self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field'])
self.assertEqual(site.custom_field_data['boolean_field'], original_cfvs['boolean_field']) self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field'])
self.assertEqual(site.custom_field_data['date_field'], original_cfvs['date_field']) self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field'])
self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field']) self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field'])
self.assertEqual(site.custom_field_data['json_field'], original_cfvs['json_field']) self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_field']) self.assertEqual(site2.custom_field_data['choice_field'], original_cfvs['choice_field'])
def test_minimum_maximum_values_validation(self): 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.add_permissions('dcim.change_site')
self.cf_integer.validation_minimum = 10 cf_integer = CustomField.objects.get(name='number_field')
self.cf_integer.validation_maximum = 20 cf_integer.validation_minimum = 10
self.cf_integer.save() cf_integer.validation_maximum = 20
cf_integer.save()
data = {'custom_fields': {'number_field': 9}} data = {'custom_fields': {'number_field': 9}}
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
@ -558,11 +569,13 @@ class CustomFieldAPITest(APITestCase):
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
def test_regex_validation(self): 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.add_permissions('dcim.change_site')
self.cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters cf_text = CustomField.objects.get(name='text_field')
self.cf_text.save() cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters
cf_text.save()
data = {'custom_fields': {'text_field': 'ABC123'}} data = {'custom_fields': {'text_field': 'ABC123'}}
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)

View File

@ -38,10 +38,20 @@ class CustomFieldModelFormTest(TestCase):
cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES) cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES)
cf_select.content_types.set([obj_type]) cf_select.content_types.set([obj_type])
cf_multiselect = CustomField.objects.create(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, cf_multiselect = CustomField.objects.create(
choices=CHOICES) name='multiselect',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choices=CHOICES
)
cf_multiselect.content_types.set([obj_type]) 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])
def test_empty_values(self): def test_empty_values(self):
""" """
Test that empty custom field values are stored as null Test that empty custom field values are stored as null

View File

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

View File

@ -24,6 +24,8 @@
<pre>{{ value|render_json }}</pre> <pre>{{ value|render_json }}</pre>
{% elif field.type == 'multiselect' and value %} {% elif field.type == 'multiselect' and value %}
{{ value|join:", " }} {{ value|join:", " }}
{% elif field.type == 'object' and value %}
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
{% elif value is not None %} {% elif value is not None %}
{{ value }} {{ value }}
{% elif field.required %} {% elif field.required %}