mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Extend to support the assignment of multiple objects per field
This commit is contained in:
@ -53,6 +53,9 @@ class CustomFieldsDataField(Field):
|
|||||||
if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||||
serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested')
|
serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested')
|
||||||
value = serializer(value, context=self.parent.context).data
|
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
|
data[cf.name] = value
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
@ -17,6 +17,7 @@ class CustomFieldTypeChoices(ChoiceSet):
|
|||||||
TYPE_SELECT = 'select'
|
TYPE_SELECT = 'select'
|
||||||
TYPE_MULTISELECT = 'multiselect'
|
TYPE_MULTISELECT = 'multiselect'
|
||||||
TYPE_OBJECT = 'object'
|
TYPE_OBJECT = 'object'
|
||||||
|
TYPE_MULTIOBJECT = 'multiobject'
|
||||||
|
|
||||||
CHOICES = (
|
CHOICES = (
|
||||||
(TYPE_TEXT, 'Text'),
|
(TYPE_TEXT, 'Text'),
|
||||||
@ -28,7 +29,8 @@ 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'),
|
(TYPE_OBJECT, 'Object'),
|
||||||
|
(TYPE_MULTIOBJECT, 'Multiple objects'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -16,8 +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, DynamicModelChoiceField, LaxURLField, StaticSelectMultiple, StaticSelect,
|
CSVChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, LaxURLField,
|
||||||
add_blank_choice,
|
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
|
||||||
@ -61,7 +61,6 @@ class CustomField(ChangeLoggedModel):
|
|||||||
null=True,
|
null=True,
|
||||||
help_text='The type of NetBox object this field maps to (for object fields)'
|
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,
|
||||||
@ -247,17 +246,26 @@ class CustomField(ChangeLoggedModel):
|
|||||||
"""
|
"""
|
||||||
Prepare a value for storage as JSON data.
|
Prepare a value for storage as JSON data.
|
||||||
"""
|
"""
|
||||||
if self.type == CustomFieldTypeChoices.TYPE_OBJECT and value is not None:
|
if value is None:
|
||||||
|
return value
|
||||||
|
if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||||
return value.pk
|
return value.pk
|
||||||
|
if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||||
|
return [obj.pk for obj in value]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def deserialize(self, value):
|
def deserialize(self, value):
|
||||||
"""
|
"""
|
||||||
Convert JSON data to a Python object suitable for the field type.
|
Convert JSON data to a Python object suitable for the field type.
|
||||||
"""
|
"""
|
||||||
if self.type == CustomFieldTypeChoices.TYPE_OBJECT and value is not None:
|
if value is None:
|
||||||
|
return value
|
||||||
|
if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||||
model = self.object_type.model_class()
|
model = self.object_type.model_class()
|
||||||
return model.objects.filter(pk=value).first()
|
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
|
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):
|
||||||
@ -335,6 +343,15 @@ class CustomField(ChangeLoggedModel):
|
|||||||
initial=initial
|
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
|
# Text
|
||||||
else:
|
else:
|
||||||
if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT:
|
if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT:
|
||||||
|
@ -206,6 +206,9 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
vlans = (
|
vlans = (
|
||||||
VLAN(name='VLAN 1', vid=1),
|
VLAN(name='VLAN 1', vid=1),
|
||||||
VLAN(name='VLAN 2', vid=2),
|
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)
|
VLAN.objects.bulk_create(vlans)
|
||||||
|
|
||||||
@ -226,6 +229,12 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
object_type=ContentType.objects.get_for_model(VLAN),
|
object_type=ContentType.objects.get_for_model(VLAN),
|
||||||
default=vlans[0].pk,
|
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:
|
for cf in custom_fields:
|
||||||
cf.save()
|
cf.save()
|
||||||
@ -250,6 +259,7 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
custom_fields[6].name: '{"foo": 1, "bar": 2}',
|
custom_fields[6].name: '{"foo": 1, "bar": 2}',
|
||||||
custom_fields[7].name: 'Bar',
|
custom_fields[7].name: 'Bar',
|
||||||
custom_fields[8].name: vlans[1].pk,
|
custom_fields[8].name: vlans[1].pk,
|
||||||
|
custom_fields[9].name: [vlans[2].pk, vlans[3].pk],
|
||||||
}
|
}
|
||||||
sites[1].save()
|
sites[1].save()
|
||||||
|
|
||||||
@ -273,6 +283,7 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
'json_field': None,
|
'json_field': None,
|
||||||
'choice_field': None,
|
'choice_field': None,
|
||||||
'object_field': None,
|
'object_field': None,
|
||||||
|
'multiobject_field': None,
|
||||||
})
|
})
|
||||||
|
|
||||||
def test_get_single_object_with_custom_field_data(self):
|
def test_get_single_object_with_custom_field_data(self):
|
||||||
@ -295,6 +306,10 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
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'])
|
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):
|
def test_create_single_object_with_defaults(self):
|
||||||
"""
|
"""
|
||||||
@ -324,6 +339,10 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
self.assertEqual(response_cf['json_field'], cf_defaults['json_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['choice_field'], cf_defaults['choice_field'])
|
||||||
self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_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
|
# Validate database data
|
||||||
site = Site.objects.get(pk=response.data['id'])
|
site = Site.objects.get(pk=response.data['id'])
|
||||||
@ -336,6 +355,7 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_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['choice_field'], cf_defaults['choice_field'])
|
||||||
self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_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):
|
def test_create_single_object_with_values(self):
|
||||||
"""
|
"""
|
||||||
@ -354,6 +374,7 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
'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,
|
'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')
|
url = reverse('dcim-api:site-list')
|
||||||
@ -374,6 +395,10 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
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'])
|
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
|
# Validate database data
|
||||||
site = Site.objects.get(pk=response.data['id'])
|
site = Site.objects.get(pk=response.data['id'])
|
||||||
@ -386,6 +411,7 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
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'])
|
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):
|
def test_create_multiple_objects_with_defaults(self):
|
||||||
"""
|
"""
|
||||||
@ -429,6 +455,10 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
self.assertEqual(response_cf['json_field'], cf_defaults['json_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['choice_field'], cf_defaults['choice_field'])
|
||||||
self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_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
|
# Validate database data
|
||||||
site = Site.objects.get(pk=response.data[i]['id'])
|
site = Site.objects.get(pk=response.data[i]['id'])
|
||||||
@ -441,6 +471,7 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_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['choice_field'], cf_defaults['choice_field'])
|
||||||
self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_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):
|
def test_create_multiple_objects_with_values(self):
|
||||||
"""
|
"""
|
||||||
@ -456,6 +487,7 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
'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,
|
'object_field': VLAN.objects.get(vid=2).pk,
|
||||||
|
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
|
||||||
}
|
}
|
||||||
data = (
|
data = (
|
||||||
{
|
{
|
||||||
@ -493,6 +525,10 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
self.assertEqual(response_cf['url_field'], custom_field_data['url_field'])
|
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['json_field'], custom_field_data['json_field'])
|
||||||
self.assertEqual(response_cf['choice_field'], custom_field_data['choice_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
|
# Validate database data
|
||||||
site = Site.objects.get(pk=response.data[i]['id'])
|
site = Site.objects.get(pk=response.data[i]['id'])
|
||||||
@ -504,6 +540,7 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field'])
|
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['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['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):
|
def test_update_single_object_with_values(self):
|
||||||
"""
|
"""
|
||||||
@ -534,6 +571,10 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
|
self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
|
||||||
self.assertEqual(response_cf['json_field'], original_cfvs['json_field'])
|
self.assertEqual(response_cf['json_field'], original_cfvs['json_field'])
|
||||||
self.assertEqual(response_cf['choice_field'], original_cfvs['choice_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
|
# Validate database data
|
||||||
site2.refresh_from_db()
|
site2.refresh_from_db()
|
||||||
@ -545,6 +586,7 @@ class CustomFieldAPITest(APITestCase):
|
|||||||
self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_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['json_field'], original_cfvs['json_field'])
|
||||||
self.assertEqual(site2.custom_field_data['choice_field'], original_cfvs['choice_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):
|
def test_minimum_maximum_values_validation(self):
|
||||||
site2 = Site.objects.get(name='Site 2')
|
site2 = Site.objects.get(name='Site 2')
|
||||||
|
@ -3,14 +3,14 @@
|
|||||||
{% with custom_fields=object.get_custom_fields %}
|
{% with custom_fields=object.get_custom_fields %}
|
||||||
{% if custom_fields %}
|
{% if custom_fields %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header">
|
<h5 class="card-header">Custom Fields</h5>
|
||||||
Custom Fields
|
|
||||||
</h5>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<table class="table table-hover attr-table">
|
<table class="table table-hover attr-table">
|
||||||
{% for field, value in custom_fields.items %}
|
{% for field, value in custom_fields.items %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span title="{{ field.description|escape }}">{{ field }}</span></td>
|
<td>
|
||||||
|
<span title="{{ field.description|escape }}">{{ field }}</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if field.type == 'longtext' and value %}
|
{% if field.type == 'longtext' and value %}
|
||||||
{{ value|render_markdown }}
|
{{ value|render_markdown }}
|
||||||
@ -26,6 +26,14 @@
|
|||||||
{{ value|join:", " }}
|
{{ value|join:", " }}
|
||||||
{% elif field.type == 'object' and value %}
|
{% elif field.type == 'object' and value %}
|
||||||
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
|
<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 %}
|
{% elif value is not None %}
|
||||||
{{ value }}
|
{{ value }}
|
||||||
{% elif field.required %}
|
{% elif field.required %}
|
||||||
|
Reference in New Issue
Block a user