From 271b7adeb830eaafa6bb117c49aee3ceff7b1adc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 5 Jan 2022 17:05:54 -0500 Subject: [PATCH] Extend to support the assignment of multiple objects per field --- netbox/extras/api/customfields.py | 3 ++ netbox/extras/choices.py | 4 +- netbox/extras/models/customfields.py | 27 +++++++++--- netbox/extras/tests/test_customfields.py | 42 +++++++++++++++++++ .../templates/inc/panels/custom_fields.html | 16 +++++-- 5 files changed, 82 insertions(+), 10 deletions(-) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index f2f4b69a6..fd6e1f550 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -53,6 +53,9 @@ class CustomFieldsDataField(Field): 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 diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 5c18f2705..0632c2b1f 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -17,6 +17,7 @@ class CustomFieldTypeChoices(ChoiceSet): TYPE_SELECT = 'select' TYPE_MULTISELECT = 'multiselect' TYPE_OBJECT = 'object' + TYPE_MULTIOBJECT = 'multiobject' CHOICES = ( (TYPE_TEXT, 'Text'), @@ -28,7 +29,8 @@ class CustomFieldTypeChoices(ChoiceSet): (TYPE_JSON, 'JSON'), (TYPE_SELECT, 'Selection'), (TYPE_MULTISELECT, 'Multiple selection'), - (TYPE_OBJECT, 'NetBox object'), + (TYPE_OBJECT, 'Object'), + (TYPE_MULTIOBJECT, 'Multiple objects'), ) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index fa65cbdee..99c483857 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -16,8 +16,8 @@ from extras.utils import FeatureQuery, extras_features from netbox.models import ChangeLoggedModel from utilities import filters from utilities.forms import ( - CSVChoiceField, DatePicker, DynamicModelChoiceField, 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 @@ -61,7 +61,6 @@ class CustomField(ChangeLoggedModel): null=True, help_text='The type of NetBox object this field maps to (for object fields)' ) - name = models.CharField( max_length=50, unique=True, @@ -247,17 +246,26 @@ class CustomField(ChangeLoggedModel): """ 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 + if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + return [obj.pk for obj in value] 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: + 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): @@ -335,6 +343,15 @@ class CustomField(ChangeLoggedModel): 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: diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index df803ce1b..657c597f2 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -206,6 +206,9 @@ class CustomFieldAPITest(APITestCase): 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) @@ -226,6 +229,12 @@ class CustomFieldAPITest(APITestCase): 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() @@ -250,6 +259,7 @@ class CustomFieldAPITest(APITestCase): 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], } sites[1].save() @@ -273,6 +283,7 @@ class CustomFieldAPITest(APITestCase): 'json_field': None, 'choice_field': None, 'object_field': None, + 'multiobject_field': None, }) 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']['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): """ @@ -324,6 +339,10 @@ class CustomFieldAPITest(APITestCase): 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']) @@ -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['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): """ @@ -354,6 +374,7 @@ class CustomFieldAPITest(APITestCase): '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') @@ -374,6 +395,10 @@ class CustomFieldAPITest(APITestCase): 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']) @@ -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['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): """ @@ -429,6 +455,10 @@ class CustomFieldAPITest(APITestCase): 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']) @@ -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['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): """ @@ -456,6 +487,7 @@ class CustomFieldAPITest(APITestCase): '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 = ( { @@ -493,6 +525,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']) @@ -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['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): """ @@ -534,6 +571,10 @@ 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 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['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): site2 = Site.objects.get(name='Site 2') diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index 46636504e..c8838fa80 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -3,14 +3,14 @@ {% with custom_fields=object.get_custom_fields %} {% if custom_fields %}
-
- Custom Fields -
+
Custom Fields
{% for field, value in custom_fields.items %} - +
{{ field }} + {{ field }} + {% if field.type == 'longtext' and value %} {{ value|render_markdown }} @@ -26,6 +26,14 @@ {{ value|join:", " }} {% elif field.type == 'object' and value %} {{ value }} + {% elif field.type == 'multiobject' and value %} + {% if value %} +
    + {% for obj in value %} +
  • {{ obj }}
  • + {% endfor %} +
+ {% endif %} {% elif value is not None %} {{ value }} {% elif field.required %}