mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-18 13:06:30 -06:00
Merge pull request #8261 from netbox-community/7006-custom-object-fields
Closes #7006: Custom object fields
This commit is contained in:
commit
453f2ab02d
@ -19,6 +19,8 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net
|
||||
* JSON: Arbitrary data stored in JSON format
|
||||
* Selection: A selection of one of several pre-defined custom choices
|
||||
* Multiple selection: A selection field which supports the assignment of multiple values
|
||||
* Object: A single NetBox object of the type defined by `object_type`
|
||||
* Multiple object: One or more NetBox objects of the type defined by `object_type`
|
||||
|
||||
Each custom field must have a name. This should be a simple database-friendly string (e.g. `tps_report`) and may contain only alphanumeric characters and underscores. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form.
|
||||
|
||||
@ -41,3 +43,7 @@ NetBox supports limited custom validation for custom field values. Following are
|
||||
Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible.
|
||||
|
||||
If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected.
|
||||
|
||||
### Custom Object Fields
|
||||
|
||||
An object or multi-object custom field can be used to refer to a particular NetBox object or objects as the "value" for a custom field. These custom fields must define an `object_type`, which determines the type of object to which custom field instances point.
|
||||
|
@ -22,6 +22,12 @@ A new REST API endpoint has been added at `/api/ipam/vlan-groups/<pk>/available-
|
||||
|
||||
A new model has been introduced to represent function roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional.
|
||||
|
||||
#### Custom Object Fields ([#7006](https://github.com/netbox-community/netbox/issues/7006))
|
||||
|
||||
Two new types of custom field have been added: object and multi-object. These can be used to associate objects with other objects in NetBox. For example, you might create a custom field named `primary_site` on the tenant model so that a particular site can be associated with each tenant as its primary. The multi-object custom field type allows for the assignment of one or more objects of the same type.
|
||||
|
||||
Custom field object assignment is fully supported in the REST API, and functions similarly to normal foreign key relations. Nested representations are provided for each custom field object.
|
||||
|
||||
#### Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844))
|
||||
|
||||
Several new models have been added to support field-replaceable device modules, such as those within a chassis-based switch or router. Similar to devices, each module is instantiated from a user-defined module type, and can have components associated with it. These components become available to the parent device once the module has been installed within a module bay. This makes it very convenient to replicate the addition and deletion of device components as modules are installed and removed.
|
||||
@ -96,6 +102,8 @@ Inventory item templates can be arranged hierarchically within a device type, an
|
||||
* Removed the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields
|
||||
* extras.ConfigContext
|
||||
* Add `cluster_types` field
|
||||
* extras.CustomField
|
||||
* Added `object_type` field
|
||||
* ipam.VLANGroup
|
||||
* Added the `/availables-vlans/` endpoint
|
||||
* Added the `min_vid` and `max_vid` fields
|
||||
|
@ -1,6 +1,7 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from rest_framework.fields import Field
|
||||
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.models import CustomField
|
||||
|
||||
|
||||
@ -44,9 +45,20 @@ class CustomFieldsDataField(Field):
|
||||
return self._custom_fields
|
||||
|
||||
def to_representation(self, obj):
|
||||
return {
|
||||
cf.name: obj.get(cf.name) for cf in self._get_custom_fields()
|
||||
}
|
||||
# TODO: Fix circular import
|
||||
from utilities.api import get_serializer_for_model
|
||||
data = {}
|
||||
for cf in self._get_custom_fields():
|
||||
value = cf.deserialize(obj.get(cf.name))
|
||||
if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested')
|
||||
value = serializer(value, context=self.parent.context).data
|
||||
elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested')
|
||||
value = serializer(value, many=True, context=self.parent.context).data
|
||||
data[cf.name] = value
|
||||
|
||||
return data
|
||||
|
||||
def to_internal_value(self, data):
|
||||
# If updating an existing instance, start with existing custom_field_data
|
||||
|
@ -16,6 +16,8 @@ class CustomFieldTypeChoices(ChoiceSet):
|
||||
TYPE_JSON = 'json'
|
||||
TYPE_SELECT = 'select'
|
||||
TYPE_MULTISELECT = 'multiselect'
|
||||
TYPE_OBJECT = 'object'
|
||||
TYPE_MULTIOBJECT = 'multiobject'
|
||||
|
||||
CHOICES = (
|
||||
(TYPE_TEXT, 'Text'),
|
||||
@ -27,6 +29,8 @@ class CustomFieldTypeChoices(ChoiceSet):
|
||||
(TYPE_JSON, 'JSON'),
|
||||
(TYPE_SELECT, 'Selection'),
|
||||
(TYPE_MULTISELECT, 'Multiple selection'),
|
||||
(TYPE_OBJECT, 'Object'),
|
||||
(TYPE_MULTIOBJECT, 'Multiple objects'),
|
||||
)
|
||||
|
||||
|
||||
|
@ -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/0069_custom_object_field.py
Normal file
18
netbox/extras/migrations/0069_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', '0068_configcontext_cluster_types'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customfield',
|
||||
name='object_type',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'),
|
||||
),
|
||||
]
|
@ -16,7 +16,8 @@ from extras.utils import FeatureQuery, extras_features
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from utilities import filters
|
||||
from utilities.forms import (
|
||||
CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
|
||||
CSVChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, LaxURLField,
|
||||
StaticSelectMultiple, StaticSelect, add_blank_choice,
|
||||
)
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.validators import validate_regex
|
||||
@ -50,7 +51,15 @@ class CustomField(ChangeLoggedModel):
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=CustomFieldTypeChoices,
|
||||
default=CustomFieldTypeChoices.TYPE_TEXT
|
||||
default=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
help_text='The type of data this custom field holds'
|
||||
)
|
||||
object_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.PROTECT,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='The type of NetBox object this field maps to (for object fields)'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
@ -122,7 +131,6 @@ class CustomField(ChangeLoggedModel):
|
||||
null=True,
|
||||
help_text='Comma-separated list of available choices (for selection fields)'
|
||||
)
|
||||
|
||||
objects = CustomFieldManager()
|
||||
|
||||
class Meta:
|
||||
@ -234,6 +242,43 @@ class CustomField(ChangeLoggedModel):
|
||||
'default': f"The specified default value ({self.default}) is not listed as an available choice."
|
||||
})
|
||||
|
||||
# Object fields must define an object_type; other fields must not
|
||||
if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
|
||||
if not self.object_type:
|
||||
raise ValidationError({
|
||||
'object_type': "Object fields must define an object type."
|
||||
})
|
||||
elif self.object_type:
|
||||
raise ValidationError({
|
||||
'object_type': f"{self.get_type_display()} fields may not define an object type."
|
||||
})
|
||||
|
||||
def serialize(self, value):
|
||||
"""
|
||||
Prepare a value for storage as JSON data.
|
||||
"""
|
||||
if value is None:
|
||||
return value
|
||||
if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
return value.pk
|
||||
if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
return [obj.pk for obj in value] or None
|
||||
return value
|
||||
|
||||
def deserialize(self, value):
|
||||
"""
|
||||
Convert JSON data to a Python object suitable for the field type.
|
||||
"""
|
||||
if value is None:
|
||||
return value
|
||||
if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
model = self.object_type.model_class()
|
||||
return model.objects.filter(pk=value).first()
|
||||
if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
model = self.object_type.model_class()
|
||||
return model.objects.filter(pk__in=value)
|
||||
return value
|
||||
|
||||
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
|
||||
"""
|
||||
Return a form field suitable for setting a CustomField's value for an object.
|
||||
@ -300,6 +345,24 @@ class CustomField(ChangeLoggedModel):
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
|
||||
field = forms.JSONField(required=required, initial=initial)
|
||||
|
||||
# Object
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
model = self.object_type.model_class()
|
||||
field = DynamicModelChoiceField(
|
||||
queryset=model.objects.all(),
|
||||
required=required,
|
||||
initial=initial
|
||||
)
|
||||
|
||||
# Multiple objects
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
model = self.object_type.model_class()
|
||||
field = DynamicModelMultipleChoiceField(
|
||||
queryset=model.objects.all(),
|
||||
required=required,
|
||||
initial=initial
|
||||
)
|
||||
|
||||
# Text
|
||||
else:
|
||||
if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT:
|
||||
|
@ -8,13 +8,15 @@ from dcim.forms import SiteCSVForm
|
||||
from dcim.models import Site, Rack
|
||||
from extras.choices import *
|
||||
from extras.models import CustomField
|
||||
from ipam.models import VLAN
|
||||
from utilities.testing import APITestCase, TestCase
|
||||
from virtualization.models import VirtualMachine
|
||||
|
||||
|
||||
class CustomFieldTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
Site.objects.bulk_create([
|
||||
Site(name='Site A', slug='site-a'),
|
||||
@ -22,137 +24,294 @@ class CustomFieldTest(TestCase):
|
||||
Site(name='Site C', slug='site-c'),
|
||||
])
|
||||
|
||||
def test_simple_fields(self):
|
||||
DATA = (
|
||||
{
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_TEXT,
|
||||
},
|
||||
'value': 'Foobar!',
|
||||
},
|
||||
{
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_LONGTEXT,
|
||||
},
|
||||
'value': 'Text with **Markdown**',
|
||||
},
|
||||
{
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
},
|
||||
'value': 0,
|
||||
},
|
||||
{
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
'validation_minimum': 1,
|
||||
'validation_maximum': 100,
|
||||
},
|
||||
'value': 42,
|
||||
},
|
||||
{
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
'validation_minimum': -100,
|
||||
'validation_maximum': -1,
|
||||
},
|
||||
'value': -42,
|
||||
},
|
||||
{
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||
},
|
||||
'value': True,
|
||||
},
|
||||
{
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||
},
|
||||
'value': False,
|
||||
},
|
||||
{
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_DATE,
|
||||
},
|
||||
'value': '2016-06-23',
|
||||
},
|
||||
{
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_URL,
|
||||
},
|
||||
'value': 'http://example.com/',
|
||||
},
|
||||
{
|
||||
'field': {
|
||||
'type': CustomFieldTypeChoices.TYPE_JSON,
|
||||
},
|
||||
'value': '{"foo": 1, "bar": 2}',
|
||||
},
|
||||
cls.object_type = ContentType.objects.get_for_model(Site)
|
||||
|
||||
def test_text_field(self):
|
||||
value = 'Foobar!'
|
||||
|
||||
# Create a custom field & check that initial value is null
|
||||
cf = CustomField.objects.create(
|
||||
name='text_field',
|
||||
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
required=False
|
||||
)
|
||||
cf.content_types.set([self.object_type])
|
||||
instance = Site.objects.first()
|
||||
self.assertIsNone(instance.custom_field_data[cf.name])
|
||||
|
||||
obj_type = ContentType.objects.get_for_model(Site)
|
||||
# Assign a value and check that it is saved
|
||||
instance.custom_field_data[cf.name] = value
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertEqual(instance.custom_field_data[cf.name], value)
|
||||
|
||||
for data in DATA:
|
||||
# Delete the stored value and check that it is now null
|
||||
instance.custom_field_data.pop(cf.name)
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
||||
|
||||
# Create a custom field
|
||||
cf = CustomField(name='my_field', required=False, **data['field'])
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
def test_longtext_field(self):
|
||||
value = 'A' * 256
|
||||
|
||||
# Check that the field has a null initial value
|
||||
site = Site.objects.first()
|
||||
self.assertIsNone(site.custom_field_data[cf.name])
|
||||
# Create a custom field & check that initial value is null
|
||||
cf = CustomField.objects.create(
|
||||
name='longtext_field',
|
||||
type=CustomFieldTypeChoices.TYPE_LONGTEXT,
|
||||
required=False
|
||||
)
|
||||
cf.content_types.set([self.object_type])
|
||||
instance = Site.objects.first()
|
||||
self.assertIsNone(instance.custom_field_data[cf.name])
|
||||
|
||||
# Assign a value to the first Site
|
||||
site.custom_field_data[cf.name] = data['value']
|
||||
site.save()
|
||||
# Assign a value and check that it is saved
|
||||
instance.custom_field_data[cf.name] = value
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertEqual(instance.custom_field_data[cf.name], value)
|
||||
|
||||
# Retrieve the stored value
|
||||
site.refresh_from_db()
|
||||
self.assertEqual(site.custom_field_data[cf.name], data['value'])
|
||||
# Delete the stored value and check that it is now null
|
||||
instance.custom_field_data.pop(cf.name)
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
||||
|
||||
# Delete the stored value
|
||||
site.custom_field_data.pop(cf.name)
|
||||
site.save()
|
||||
site.refresh_from_db()
|
||||
self.assertIsNone(site.custom_field_data.get(cf.name))
|
||||
def test_integer_field(self):
|
||||
|
||||
# Delete the custom field
|
||||
cf.delete()
|
||||
# Create a custom field & check that initial value is null
|
||||
cf = CustomField.objects.create(
|
||||
name='integer_field',
|
||||
type=CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
required=False
|
||||
)
|
||||
cf.content_types.set([self.object_type])
|
||||
instance = Site.objects.first()
|
||||
self.assertIsNone(instance.custom_field_data[cf.name])
|
||||
|
||||
for value in (123456, 0, -123456):
|
||||
|
||||
# Assign a value and check that it is saved
|
||||
instance.custom_field_data[cf.name] = value
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertEqual(instance.custom_field_data[cf.name], value)
|
||||
|
||||
# Delete the stored value and check that it is now null
|
||||
instance.custom_field_data.pop(cf.name)
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
||||
|
||||
def test_boolean_field(self):
|
||||
|
||||
# Create a custom field & check that initial value is null
|
||||
cf = CustomField.objects.create(
|
||||
name='boolean_field',
|
||||
type=CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
required=False
|
||||
)
|
||||
cf.content_types.set([self.object_type])
|
||||
instance = Site.objects.first()
|
||||
self.assertIsNone(instance.custom_field_data[cf.name])
|
||||
|
||||
for value in (True, False):
|
||||
|
||||
# Assign a value and check that it is saved
|
||||
instance.custom_field_data[cf.name] = value
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertEqual(instance.custom_field_data[cf.name], value)
|
||||
|
||||
# Delete the stored value and check that it is now null
|
||||
instance.custom_field_data.pop(cf.name)
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
||||
|
||||
def test_date_field(self):
|
||||
value = '2016-06-23'
|
||||
|
||||
# Create a custom field & check that initial value is null
|
||||
cf = CustomField.objects.create(
|
||||
name='date_field',
|
||||
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
required=False
|
||||
)
|
||||
cf.content_types.set([self.object_type])
|
||||
instance = Site.objects.first()
|
||||
self.assertIsNone(instance.custom_field_data[cf.name])
|
||||
|
||||
# Assign a value and check that it is saved
|
||||
instance.custom_field_data[cf.name] = value
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertEqual(instance.custom_field_data[cf.name], value)
|
||||
|
||||
# Delete the stored value and check that it is now null
|
||||
instance.custom_field_data.pop(cf.name)
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
||||
|
||||
def test_url_field(self):
|
||||
value = 'http://example.com/'
|
||||
|
||||
# Create a custom field & check that initial value is null
|
||||
cf = CustomField.objects.create(
|
||||
name='url_field',
|
||||
type=CustomFieldTypeChoices.TYPE_URL,
|
||||
required=False
|
||||
)
|
||||
cf.content_types.set([self.object_type])
|
||||
instance = Site.objects.first()
|
||||
self.assertIsNone(instance.custom_field_data[cf.name])
|
||||
|
||||
# Assign a value and check that it is saved
|
||||
instance.custom_field_data[cf.name] = value
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertEqual(instance.custom_field_data[cf.name], value)
|
||||
|
||||
# Delete the stored value and check that it is now null
|
||||
instance.custom_field_data.pop(cf.name)
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
||||
|
||||
def test_json_field(self):
|
||||
value = '{"foo": 1, "bar": 2}'
|
||||
|
||||
# Create a custom field & check that initial value is null
|
||||
cf = CustomField.objects.create(
|
||||
name='json_field',
|
||||
type=CustomFieldTypeChoices.TYPE_JSON,
|
||||
required=False
|
||||
)
|
||||
cf.content_types.set([self.object_type])
|
||||
instance = Site.objects.first()
|
||||
self.assertIsNone(instance.custom_field_data[cf.name])
|
||||
|
||||
# Assign a value and check that it is saved
|
||||
instance.custom_field_data[cf.name] = value
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertEqual(instance.custom_field_data[cf.name], value)
|
||||
|
||||
# Delete the stored value and check that it is now null
|
||||
instance.custom_field_data.pop(cf.name)
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
||||
|
||||
def test_select_field(self):
|
||||
obj_type = ContentType.objects.get_for_model(Site)
|
||||
CHOICES = ('Option A', 'Option B', 'Option C')
|
||||
value = CHOICES[1]
|
||||
|
||||
# Create a custom field
|
||||
cf = CustomField(
|
||||
# Create a custom field & check that initial value is null
|
||||
cf = CustomField.objects.create(
|
||||
name='select_field',
|
||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||
name='my_field',
|
||||
required=False,
|
||||
choices=['Option A', 'Option B', 'Option C']
|
||||
choices=CHOICES
|
||||
)
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
cf.content_types.set([self.object_type])
|
||||
instance = Site.objects.first()
|
||||
self.assertIsNone(instance.custom_field_data[cf.name])
|
||||
|
||||
# Check that the field has a null initial value
|
||||
site = Site.objects.first()
|
||||
self.assertIsNone(site.custom_field_data[cf.name])
|
||||
# Assign a value and check that it is saved
|
||||
instance.custom_field_data[cf.name] = value
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertEqual(instance.custom_field_data[cf.name], value)
|
||||
|
||||
# Assign a value to the first Site
|
||||
site.custom_field_data[cf.name] = 'Option A'
|
||||
site.save()
|
||||
# Delete the stored value and check that it is now null
|
||||
instance.custom_field_data.pop(cf.name)
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
||||
|
||||
# Retrieve the stored value
|
||||
site.refresh_from_db()
|
||||
self.assertEqual(site.custom_field_data[cf.name], 'Option A')
|
||||
def test_multiselect_field(self):
|
||||
CHOICES = ['Option A', 'Option B', 'Option C']
|
||||
value = [CHOICES[1], CHOICES[2]]
|
||||
|
||||
# Delete the stored value
|
||||
site.custom_field_data.pop(cf.name)
|
||||
site.save()
|
||||
site.refresh_from_db()
|
||||
self.assertIsNone(site.custom_field_data.get(cf.name))
|
||||
# Create a custom field & check that initial value is null
|
||||
cf = CustomField.objects.create(
|
||||
name='multiselect_field',
|
||||
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||
required=False,
|
||||
choices=CHOICES
|
||||
)
|
||||
cf.content_types.set([self.object_type])
|
||||
instance = Site.objects.first()
|
||||
self.assertIsNone(instance.custom_field_data[cf.name])
|
||||
|
||||
# Delete the custom field
|
||||
cf.delete()
|
||||
# Assign a value and check that it is saved
|
||||
instance.custom_field_data[cf.name] = value
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertEqual(instance.custom_field_data[cf.name], value)
|
||||
|
||||
# Delete the stored value and check that it is now null
|
||||
instance.custom_field_data.pop(cf.name)
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
||||
|
||||
def test_object_field(self):
|
||||
value = VLAN.objects.create(name='VLAN 1', vid=1).pk
|
||||
|
||||
# Create a custom field & check that initial value is null
|
||||
cf = CustomField.objects.create(
|
||||
name='object_field',
|
||||
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
required=False
|
||||
)
|
||||
cf.content_types.set([self.object_type])
|
||||
instance = Site.objects.first()
|
||||
self.assertIsNone(instance.custom_field_data[cf.name])
|
||||
|
||||
# Assign a value and check that it is saved
|
||||
instance.custom_field_data[cf.name] = value
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertEqual(instance.custom_field_data[cf.name], value)
|
||||
|
||||
# Delete the stored value and check that it is now null
|
||||
instance.custom_field_data.pop(cf.name)
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
||||
|
||||
def test_multiobject_field(self):
|
||||
vlans = (
|
||||
VLAN(name='VLAN 1', vid=1),
|
||||
VLAN(name='VLAN 2', vid=2),
|
||||
VLAN(name='VLAN 3', vid=3),
|
||||
)
|
||||
VLAN.objects.bulk_create(vlans)
|
||||
value = [vlan.pk for vlan in vlans]
|
||||
|
||||
# Create a custom field & check that initial value is null
|
||||
cf = CustomField.objects.create(
|
||||
name='object_field',
|
||||
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
|
||||
required=False
|
||||
)
|
||||
cf.content_types.set([self.object_type])
|
||||
instance = Site.objects.first()
|
||||
self.assertIsNone(instance.custom_field_data[cf.name])
|
||||
|
||||
# Assign a value and check that it is saved
|
||||
instance.custom_field_data[cf.name] = value
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertEqual(instance.custom_field_data[cf.name], value)
|
||||
|
||||
# Delete the stored value and check that it is now null
|
||||
instance.custom_field_data.pop(cf.name)
|
||||
instance.save()
|
||||
instance.refresh_from_db()
|
||||
self.assertIsNone(instance.custom_field_data.get(cf.name))
|
||||
|
||||
def test_rename_customfield(self):
|
||||
obj_type = ContentType.objects.get_for_model(Site)
|
||||
@ -201,76 +360,77 @@ class CustomFieldAPITest(APITestCase):
|
||||
def setUpTestData(cls):
|
||||
content_type = ContentType.objects.get_for_model(Site)
|
||||
|
||||
# Text custom field
|
||||
cls.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo')
|
||||
cls.cf_text.save()
|
||||
cls.cf_text.content_types.set([content_type])
|
||||
# Create some VLANs
|
||||
vlans = (
|
||||
VLAN(name='VLAN 1', vid=1),
|
||||
VLAN(name='VLAN 2', vid=2),
|
||||
VLAN(name='VLAN 3', vid=3),
|
||||
VLAN(name='VLAN 4', vid=4),
|
||||
VLAN(name='VLAN 5', vid=5),
|
||||
)
|
||||
VLAN.objects.bulk_create(vlans)
|
||||
|
||||
# Long text custom field
|
||||
cls.cf_longtext = CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC')
|
||||
cls.cf_longtext.save()
|
||||
cls.cf_longtext.content_types.set([content_type])
|
||||
custom_fields = (
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'),
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123),
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False),
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'),
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'),
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'),
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', default='Foo', choices=(
|
||||
'Foo', 'Bar', 'Baz'
|
||||
)),
|
||||
CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
name='object_field',
|
||||
object_type=ContentType.objects.get_for_model(VLAN),
|
||||
default=vlans[0].pk,
|
||||
),
|
||||
CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
|
||||
name='multiobject_field',
|
||||
object_type=ContentType.objects.get_for_model(VLAN),
|
||||
default=[vlans[0].pk, vlans[1].pk],
|
||||
),
|
||||
)
|
||||
for cf in custom_fields:
|
||||
cf.save()
|
||||
cf.content_types.set([content_type])
|
||||
|
||||
# Integer custom field
|
||||
cls.cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123)
|
||||
cls.cf_integer.save()
|
||||
cls.cf_integer.content_types.set([content_type])
|
||||
|
||||
# Boolean custom field
|
||||
cls.cf_boolean = CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False)
|
||||
cls.cf_boolean.save()
|
||||
cls.cf_boolean.content_types.set([content_type])
|
||||
|
||||
# Date custom field
|
||||
cls.cf_date = CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01')
|
||||
cls.cf_date.save()
|
||||
cls.cf_date.content_types.set([content_type])
|
||||
|
||||
# URL custom field
|
||||
cls.cf_url = CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1')
|
||||
cls.cf_url.save()
|
||||
cls.cf_url.content_types.set([content_type])
|
||||
|
||||
# JSON custom field
|
||||
cls.cf_json = CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}')
|
||||
cls.cf_json.save()
|
||||
cls.cf_json.content_types.set([content_type])
|
||||
|
||||
# Select custom field
|
||||
cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', choices=['Foo', 'Bar', 'Baz'])
|
||||
cls.cf_select.default = 'Foo'
|
||||
cls.cf_select.save()
|
||||
cls.cf_select.content_types.set([content_type])
|
||||
|
||||
# Create some sites
|
||||
cls.sites = (
|
||||
# Create some sites *after* creating the custom fields. This ensures that
|
||||
# default values are not set for the assigned objects.
|
||||
sites = (
|
||||
Site(name='Site 1', slug='site-1'),
|
||||
Site(name='Site 2', slug='site-2'),
|
||||
)
|
||||
Site.objects.bulk_create(cls.sites)
|
||||
Site.objects.bulk_create(sites)
|
||||
|
||||
# Assign custom field values for site 2
|
||||
cls.sites[1].custom_field_data = {
|
||||
cls.cf_text.name: 'bar',
|
||||
cls.cf_longtext.name: 'DEF',
|
||||
cls.cf_integer.name: 456,
|
||||
cls.cf_boolean.name: True,
|
||||
cls.cf_date.name: '2020-01-02',
|
||||
cls.cf_url.name: 'http://example.com/2',
|
||||
cls.cf_json.name: '{"foo": 1, "bar": 2}',
|
||||
cls.cf_select.name: 'Bar',
|
||||
sites[1].custom_field_data = {
|
||||
custom_fields[0].name: 'bar',
|
||||
custom_fields[1].name: 'DEF',
|
||||
custom_fields[2].name: 456,
|
||||
custom_fields[3].name: True,
|
||||
custom_fields[4].name: '2020-01-02',
|
||||
custom_fields[5].name: 'http://example.com/2',
|
||||
custom_fields[6].name: '{"foo": 1, "bar": 2}',
|
||||
custom_fields[7].name: 'Bar',
|
||||
custom_fields[8].name: vlans[1].pk,
|
||||
custom_fields[9].name: [vlans[2].pk, vlans[3].pk],
|
||||
}
|
||||
cls.sites[1].save()
|
||||
sites[1].save()
|
||||
|
||||
def test_get_single_object_without_custom_field_data(self):
|
||||
"""
|
||||
Validate that custom fields are present on an object even if it has no values defined.
|
||||
"""
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[0].pk})
|
||||
site1 = Site.objects.get(name='Site 1')
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
|
||||
self.add_permissions('dcim.view_site')
|
||||
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(response.data['name'], self.sites[0].name)
|
||||
self.assertEqual(response.data['name'], site1.name)
|
||||
self.assertEqual(response.data['custom_fields'], {
|
||||
'text_field': None,
|
||||
'longtext_field': None,
|
||||
@ -280,18 +440,21 @@ class CustomFieldAPITest(APITestCase):
|
||||
'url_field': None,
|
||||
'json_field': None,
|
||||
'choice_field': None,
|
||||
'object_field': None,
|
||||
'multiobject_field': None,
|
||||
})
|
||||
|
||||
def test_get_single_object_with_custom_field_data(self):
|
||||
"""
|
||||
Validate that custom fields are present and correctly set for an object with values defined.
|
||||
"""
|
||||
site2_cfvs = self.sites[1].custom_field_data
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
|
||||
site2 = Site.objects.get(name='Site 2')
|
||||
site2_cfvs = site2.custom_field_data
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
|
||||
self.add_permissions('dcim.view_site')
|
||||
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(response.data['name'], self.sites[1].name)
|
||||
self.assertEqual(response.data['name'], site2.name)
|
||||
self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field'])
|
||||
self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field'])
|
||||
self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field'])
|
||||
@ -300,11 +463,19 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
|
||||
self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field'])
|
||||
self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field'])
|
||||
self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field'])
|
||||
self.assertEqual(
|
||||
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
|
||||
site2_cfvs['multiobject_field']
|
||||
)
|
||||
|
||||
def test_create_single_object_with_defaults(self):
|
||||
"""
|
||||
Create a new site with no specified custom field values and check that it received the default values.
|
||||
"""
|
||||
cf_defaults = {
|
||||
cf.name: cf.default for cf in CustomField.objects.all()
|
||||
}
|
||||
data = {
|
||||
'name': 'Site 3',
|
||||
'slug': 'site-3',
|
||||
@ -317,25 +488,32 @@ class CustomFieldAPITest(APITestCase):
|
||||
|
||||
# Validate response data
|
||||
response_cf = response.data['custom_fields']
|
||||
self.assertEqual(response_cf['text_field'], self.cf_text.default)
|
||||
self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default)
|
||||
self.assertEqual(response_cf['number_field'], self.cf_integer.default)
|
||||
self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default)
|
||||
self.assertEqual(response_cf['date_field'], self.cf_date.default)
|
||||
self.assertEqual(response_cf['url_field'], self.cf_url.default)
|
||||
self.assertEqual(response_cf['json_field'], self.cf_json.default)
|
||||
self.assertEqual(response_cf['choice_field'], self.cf_select.default)
|
||||
self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
|
||||
self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
|
||||
self.assertEqual(response_cf['number_field'], cf_defaults['number_field'])
|
||||
self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
|
||||
self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
|
||||
self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
|
||||
self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
|
||||
self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field'])
|
||||
self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field'])
|
||||
self.assertEqual(
|
||||
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
|
||||
cf_defaults['multiobject_field']
|
||||
)
|
||||
|
||||
# Validate database data
|
||||
site = Site.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default)
|
||||
self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default)
|
||||
self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default)
|
||||
self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default)
|
||||
self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default)
|
||||
self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default)
|
||||
self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default)
|
||||
self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default)
|
||||
self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
|
||||
self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
|
||||
self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field'])
|
||||
self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
|
||||
self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
|
||||
self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
|
||||
self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
|
||||
self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field'])
|
||||
self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field'])
|
||||
self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field'])
|
||||
|
||||
def test_create_single_object_with_values(self):
|
||||
"""
|
||||
@ -353,6 +531,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
'url_field': 'http://example.com/2',
|
||||
'json_field': '{"foo": 1, "bar": 2}',
|
||||
'choice_field': 'Bar',
|
||||
'object_field': VLAN.objects.get(vid=2).pk,
|
||||
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
|
||||
},
|
||||
}
|
||||
url = reverse('dcim-api:site-list')
|
||||
@ -372,6 +552,11 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response_cf['url_field'], data_cf['url_field'])
|
||||
self.assertEqual(response_cf['json_field'], data_cf['json_field'])
|
||||
self.assertEqual(response_cf['choice_field'], data_cf['choice_field'])
|
||||
self.assertEqual(response_cf['object_field']['id'], data_cf['object_field'])
|
||||
self.assertEqual(
|
||||
[obj['id'] for obj in response_cf['multiobject_field']],
|
||||
data_cf['multiobject_field']
|
||||
)
|
||||
|
||||
# Validate database data
|
||||
site = Site.objects.get(pk=response.data['id'])
|
||||
@ -383,12 +568,17 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field'])
|
||||
self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field'])
|
||||
self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field'])
|
||||
self.assertEqual(site.custom_field_data['object_field'], data_cf['object_field'])
|
||||
self.assertEqual(site.custom_field_data['multiobject_field'], data_cf['multiobject_field'])
|
||||
|
||||
def test_create_multiple_objects_with_defaults(self):
|
||||
"""
|
||||
Create three news sites with no specified custom field values and check that each received
|
||||
Create three new sites with no specified custom field values and check that each received
|
||||
the default custom field values.
|
||||
"""
|
||||
cf_defaults = {
|
||||
cf.name: cf.default for cf in CustomField.objects.all()
|
||||
}
|
||||
data = (
|
||||
{
|
||||
'name': 'Site 3',
|
||||
@ -414,25 +604,32 @@ class CustomFieldAPITest(APITestCase):
|
||||
|
||||
# Validate response data
|
||||
response_cf = response.data[i]['custom_fields']
|
||||
self.assertEqual(response_cf['text_field'], self.cf_text.default)
|
||||
self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default)
|
||||
self.assertEqual(response_cf['number_field'], self.cf_integer.default)
|
||||
self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default)
|
||||
self.assertEqual(response_cf['date_field'], self.cf_date.default)
|
||||
self.assertEqual(response_cf['url_field'], self.cf_url.default)
|
||||
self.assertEqual(response_cf['json_field'], self.cf_json.default)
|
||||
self.assertEqual(response_cf['choice_field'], self.cf_select.default)
|
||||
self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
|
||||
self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
|
||||
self.assertEqual(response_cf['number_field'], cf_defaults['number_field'])
|
||||
self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
|
||||
self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
|
||||
self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
|
||||
self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
|
||||
self.assertEqual(response_cf['choice_field'], cf_defaults['choice_field'])
|
||||
self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field'])
|
||||
self.assertEqual(
|
||||
[obj['id'] for obj in response_cf['multiobject_field']],
|
||||
cf_defaults['multiobject_field']
|
||||
)
|
||||
|
||||
# Validate database data
|
||||
site = Site.objects.get(pk=response.data[i]['id'])
|
||||
self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default)
|
||||
self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default)
|
||||
self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default)
|
||||
self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default)
|
||||
self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default)
|
||||
self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default)
|
||||
self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default)
|
||||
self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default)
|
||||
self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
|
||||
self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
|
||||
self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field'])
|
||||
self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
|
||||
self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
|
||||
self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
|
||||
self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
|
||||
self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_field'])
|
||||
self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field'])
|
||||
self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field'])
|
||||
|
||||
def test_create_multiple_objects_with_values(self):
|
||||
"""
|
||||
@ -447,6 +644,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
'url_field': 'http://example.com/2',
|
||||
'json_field': '{"foo": 1, "bar": 2}',
|
||||
'choice_field': 'Bar',
|
||||
'object_field': VLAN.objects.get(vid=2).pk,
|
||||
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
|
||||
}
|
||||
data = (
|
||||
{
|
||||
@ -484,6 +683,10 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response_cf['url_field'], custom_field_data['url_field'])
|
||||
self.assertEqual(response_cf['json_field'], custom_field_data['json_field'])
|
||||
self.assertEqual(response_cf['choice_field'], custom_field_data['choice_field'])
|
||||
self.assertEqual(
|
||||
[obj['id'] for obj in response_cf['multiobject_field']],
|
||||
custom_field_data['multiobject_field']
|
||||
)
|
||||
|
||||
# Validate database data
|
||||
site = Site.objects.get(pk=response.data[i]['id'])
|
||||
@ -495,21 +698,22 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field'])
|
||||
self.assertEqual(site.custom_field_data['json_field'], custom_field_data['json_field'])
|
||||
self.assertEqual(site.custom_field_data['choice_field'], custom_field_data['choice_field'])
|
||||
self.assertEqual(site.custom_field_data['multiobject_field'], custom_field_data['multiobject_field'])
|
||||
|
||||
def test_update_single_object_with_values(self):
|
||||
"""
|
||||
Update an object with existing custom field values. Ensure that only the updated custom field values are
|
||||
modified.
|
||||
"""
|
||||
site = self.sites[1]
|
||||
original_cfvs = {**site.custom_field_data}
|
||||
site2 = Site.objects.get(name='Site 2')
|
||||
original_cfvs = {**site2.custom_field_data}
|
||||
data = {
|
||||
'custom_fields': {
|
||||
'text_field': 'ABCD',
|
||||
'number_field': 1234,
|
||||
},
|
||||
}
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
|
||||
self.add_permissions('dcim.change_site')
|
||||
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
@ -525,25 +729,32 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
|
||||
self.assertEqual(response_cf['json_field'], original_cfvs['json_field'])
|
||||
self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field'])
|
||||
self.assertEqual(
|
||||
[obj['id'] for obj in response_cf['multiobject_field']],
|
||||
original_cfvs['multiobject_field']
|
||||
)
|
||||
|
||||
# Validate database data
|
||||
site.refresh_from_db()
|
||||
self.assertEqual(site.custom_field_data['text_field'], data['custom_fields']['text_field'])
|
||||
self.assertEqual(site.custom_field_data['number_field'], data['custom_fields']['number_field'])
|
||||
self.assertEqual(site.custom_field_data['longtext_field'], original_cfvs['longtext_field'])
|
||||
self.assertEqual(site.custom_field_data['boolean_field'], original_cfvs['boolean_field'])
|
||||
self.assertEqual(site.custom_field_data['date_field'], original_cfvs['date_field'])
|
||||
self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field'])
|
||||
self.assertEqual(site.custom_field_data['json_field'], original_cfvs['json_field'])
|
||||
self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_field'])
|
||||
site2.refresh_from_db()
|
||||
self.assertEqual(site2.custom_field_data['text_field'], data['custom_fields']['text_field'])
|
||||
self.assertEqual(site2.custom_field_data['number_field'], data['custom_fields']['number_field'])
|
||||
self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field'])
|
||||
self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field'])
|
||||
self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field'])
|
||||
self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field'])
|
||||
self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field'])
|
||||
self.assertEqual(site2.custom_field_data['choice_field'], original_cfvs['choice_field'])
|
||||
self.assertEqual(site2.custom_field_data['multiobject_field'], original_cfvs['multiobject_field'])
|
||||
|
||||
def test_minimum_maximum_values_validation(self):
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
|
||||
site2 = Site.objects.get(name='Site 2')
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
|
||||
self.add_permissions('dcim.change_site')
|
||||
|
||||
self.cf_integer.validation_minimum = 10
|
||||
self.cf_integer.validation_maximum = 20
|
||||
self.cf_integer.save()
|
||||
cf_integer = CustomField.objects.get(name='number_field')
|
||||
cf_integer.validation_minimum = 10
|
||||
cf_integer.validation_maximum = 20
|
||||
cf_integer.save()
|
||||
|
||||
data = {'custom_fields': {'number_field': 9}}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
@ -558,11 +769,13 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
def test_regex_validation(self):
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
|
||||
site2 = Site.objects.get(name='Site 2')
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
|
||||
self.add_permissions('dcim.change_site')
|
||||
|
||||
self.cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters
|
||||
self.cf_text.save()
|
||||
cf_text = CustomField.objects.get(name='text_field')
|
||||
cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters
|
||||
cf_text.save()
|
||||
|
||||
data = {'custom_fields': {'text_field': 'ABC123'}}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
|
@ -38,10 +38,27 @@ class CustomFieldModelFormTest(TestCase):
|
||||
cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES)
|
||||
cf_select.content_types.set([obj_type])
|
||||
|
||||
cf_multiselect = CustomField.objects.create(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||
choices=CHOICES)
|
||||
cf_multiselect = CustomField.objects.create(
|
||||
name='multiselect',
|
||||
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||
choices=CHOICES
|
||||
)
|
||||
cf_multiselect.content_types.set([obj_type])
|
||||
|
||||
cf_object = CustomField.objects.create(
|
||||
name='object',
|
||||
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
object_type=ContentType.objects.get_for_model(Site)
|
||||
)
|
||||
cf_object.content_types.set([obj_type])
|
||||
|
||||
cf_multiobject = CustomField.objects.create(
|
||||
name='multiobject',
|
||||
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
|
||||
object_type=ContentType.objects.get_for_model(Site)
|
||||
)
|
||||
cf_multiobject.content_types.set([obj_type])
|
||||
|
||||
def test_empty_values(self):
|
||||
"""
|
||||
Test that empty custom field values are stored as null
|
||||
|
@ -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():
|
||||
|
@ -285,7 +285,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
return get_permission_for_model(self.queryset.model, 'change')
|
||||
|
||||
def _update_objects(self, form, request):
|
||||
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
|
||||
custom_fields = getattr(form, 'custom_fields', [])
|
||||
standard_fields = [
|
||||
field for field in form.fields if field not in custom_fields + ['pk']
|
||||
]
|
||||
@ -327,7 +327,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
if name in form.nullable_fields and name in nullified_fields:
|
||||
obj.custom_field_data[name] = None
|
||||
elif name in form.changed_data:
|
||||
obj.custom_field_data[name] = form.cleaned_data[name]
|
||||
obj.custom_field_data[name] = form.fields[name].prepare_value(form.cleaned_data[name])
|
||||
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
|
@ -3,14 +3,14 @@
|
||||
{% with custom_fields=object.get_custom_fields %}
|
||||
{% if custom_fields %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Custom Fields
|
||||
</h5>
|
||||
<h5 class="card-header">Custom Fields</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
{% for field, value in custom_fields.items %}
|
||||
<tr>
|
||||
<td><span title="{{ field.description|escape }}">{{ field }}</span></td>
|
||||
<td>
|
||||
<span title="{{ field.description|escape }}">{{ field }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if field.type == 'longtext' and value %}
|
||||
{{ value|render_markdown }}
|
||||
@ -24,6 +24,16 @@
|
||||
<pre>{{ value|render_json }}</pre>
|
||||
{% elif field.type == 'multiselect' and value %}
|
||||
{{ value|join:", " }}
|
||||
{% elif field.type == 'object' and value %}
|
||||
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
|
||||
{% elif field.type == 'multiobject' and value %}
|
||||
{% if value %}
|
||||
<ul>
|
||||
{% for obj in value %}
|
||||
<li><a href="{{ obj.get_absolute_url }}">{{ obj }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% elif value is not None %}
|
||||
{{ value }}
|
||||
{% elif field.required %}
|
||||
|
Loading…
Reference in New Issue
Block a user