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 rest_framework.fields import Field
|
||||
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.models import CustomField
|
||||
|
||||
|
||||
@ -44,9 +45,17 @@ 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
|
||||
data[cf.name] = value
|
||||
|
||||
return data
|
||||
|
||||
def to_internal_value(self, data):
|
||||
# If updating an existing instance, start with existing custom_field_data
|
||||
|
@ -16,6 +16,7 @@ class CustomFieldTypeChoices(ChoiceSet):
|
||||
TYPE_JSON = 'json'
|
||||
TYPE_SELECT = 'select'
|
||||
TYPE_MULTISELECT = 'multiselect'
|
||||
TYPE_OBJECT = 'object'
|
||||
|
||||
CHOICES = (
|
||||
(TYPE_TEXT, 'Text'),
|
||||
@ -27,6 +28,7 @@ class CustomFieldTypeChoices(ChoiceSet):
|
||||
(TYPE_JSON, 'JSON'),
|
||||
(TYPE_SELECT, 'Selection'),
|
||||
(TYPE_MULTISELECT, 'Multiple selection'),
|
||||
(TYPE_OBJECT, 'NetBox object'),
|
||||
)
|
||||
|
||||
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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')),
|
||||
|
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 utilities import filters
|
||||
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.validators import validate_regex
|
||||
@ -50,8 +51,17 @@ 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,
|
||||
unique=True,
|
||||
@ -122,7 +132,6 @@ class CustomField(ChangeLoggedModel):
|
||||
null=True,
|
||||
help_text='Comma-separated list of available choices (for selection fields)'
|
||||
)
|
||||
|
||||
objects = CustomFieldManager()
|
||||
|
||||
class Meta:
|
||||
@ -234,6 +243,23 @@ class CustomField(ChangeLoggedModel):
|
||||
'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):
|
||||
"""
|
||||
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:
|
||||
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
|
||||
else:
|
||||
if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT:
|
||||
|
@ -8,6 +8,7 @@ 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
|
||||
|
||||
@ -201,76 +202,67 @@ 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.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,
|
||||
),
|
||||
)
|
||||
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,
|
||||
}
|
||||
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 +272,20 @@ class CustomFieldAPITest(APITestCase):
|
||||
'url_field': None,
|
||||
'json_field': None,
|
||||
'choice_field': None,
|
||||
'object_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 +294,15 @@ 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'])
|
||||
|
||||
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 +315,27 @@ 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'])
|
||||
|
||||
# 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'])
|
||||
|
||||
def test_create_single_object_with_values(self):
|
||||
"""
|
||||
@ -353,6 +353,7 @@ 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,
|
||||
},
|
||||
}
|
||||
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['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'])
|
||||
|
||||
# Validate database data
|
||||
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['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'])
|
||||
|
||||
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 +420,27 @@ 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'])
|
||||
|
||||
# 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'])
|
||||
|
||||
def test_create_multiple_objects_with_values(self):
|
||||
"""
|
||||
@ -447,6 +455,7 @@ 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,
|
||||
}
|
||||
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
|
||||
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)
|
||||
@ -527,23 +536,25 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response_cf['choice_field'], original_cfvs['choice_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'])
|
||||
|
||||
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 +569,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)
|
||||
|
@ -38,10 +38,20 @@ 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])
|
||||
|
||||
def test_empty_values(self):
|
||||
"""
|
||||
Test that empty custom field values are stored as null
|
||||
|
@ -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():
|
||||
|
@ -24,6 +24,8 @@
|
||||
<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 value is not None %}
|
||||
{{ value }}
|
||||
{% elif field.required %}
|
||||
|
Reference in New Issue
Block a user