2
0
Эх сурвалжийг харах

Extend to support the assignment of multiple objects per field

jeremystretch 4 жил өмнө
parent
commit
271b7adeb8

+ 3 - 0
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

+ 3 - 1
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'),
     )
 
 

+ 22 - 5
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:

+ 42 - 0
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')

+ 12 - 4
netbox/templates/inc/panels/custom_fields.html

@@ -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 }}
@@ -26,6 +26,14 @@
                                     {{ 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 %}