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:
@ -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
|
||||||
|
@ -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'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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')),
|
||||||
|
18
netbox/extras/migrations/0068_custom_object_field.py
Normal file
18
netbox/extras/migrations/0068_custom_object_field.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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:
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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():
|
||||||
|
@ -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 %}
|
||||||
|
Reference in New Issue
Block a user