From 7234b45a0cb7fcbd7aacf2907bdeb22f0e2ffbc5 Mon Sep 17 00:00:00 2001 From: TomGrozev Date: Fri, 11 Dec 2020 13:10:17 +1100 Subject: [PATCH] Adds support for multi select custom fields --- docs/release-notes/version-2.10.md | 1 + netbox/extras/admin.py | 2 +- netbox/extras/models/customfields.py | 32 +++++++++++++++---- netbox/extras/tests/test_customfields.py | 32 +++++++++++++++++++ netbox/templates/inc/custom_fields_panel.html | 2 ++ 5 files changed, 61 insertions(+), 8 deletions(-) diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 15ac1fc04..b29628473 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -62,6 +62,7 @@ All end-to-end cable paths are now cached using the new CablePath backend model. ### Enhancements +* [#5451](https://github.com/netbox-community/netbox/issues/5451) - Add ability to create multi selection custom fields * [#609](https://github.com/netbox-community/netbox/issues/609) - Add min/max value and regex validation for custom fields * [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines * [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index a4786610d..38bf04e9a 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -114,7 +114,7 @@ class CustomFieldAdmin(admin.ModelAdmin): }), ('Choices', { 'description': 'A selection field must have two or more choices assigned to it.', - 'fields': ('choices',) + 'fields': ('choices','multiple_selection') }) ) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index be06eea8a..3304c4a70 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -12,7 +12,7 @@ from django.utils.safestring import mark_safe from extras.choices import * from extras.utils import FeatureQuery -from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice +from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2Multiple, StaticSelect2, add_blank_choice from utilities.querysets import RestrictedQuerySet from utilities.validators import validate_regex @@ -151,6 +151,10 @@ class CustomField(models.Model): null=True, help_text='Comma-separated list of available choices (for selection fields)' ) + multiple_selection = models.BooleanField( + default=False, + help_text='Allow multiple choice selection' + ) objects = CustomFieldManager() @@ -203,6 +207,12 @@ class CustomField(models.Model): 'choices': "Choices may be set only for custom selection fields." }) + # Multiple selection can only be set for selection fields + if self.multiple_selection and self.type != CustomFieldTypeChoices.TYPE_SELECT: + raise ValidationError({ + 'multiple_selection': "Multiple selection can only be set for selection fields." + }) + # A selection field must have at least two choices defined if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.choices and len(self.choices) < 2: raise ValidationError({ @@ -262,9 +272,13 @@ class CustomField(models.Model): if set_initial and default_choice: initial = default_choice - field_class = CSVChoiceField if for_csv_import else forms.ChoiceField + # Use multiple choice field if multi_selection is enabled + default_field_class = forms.MultipleChoiceField if self.multiple_selection else forms.ChoiceField + select_widget = StaticSelect2Multiple() if self.multiple_selection else StaticSelect2() + + field_class = CSVChoiceField if for_csv_import else default_field_class field = field_class( - choices=choices, required=required, initial=initial, widget=StaticSelect2() + choices=choices, required=required, initial=initial, widget=select_widget ) # URL @@ -324,10 +338,14 @@ class CustomField(models.Model): # Validate selected choice if self.type == CustomFieldTypeChoices.TYPE_SELECT: - if value not in self.choices: - raise ValidationError( - f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}" - ) + if not isinstance(value, list): + value = [value] + + for val in value: + if val not in self.choices: + raise ValidationError( + f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}" + ) elif self.required: raise ValidationError("Required field cannot be empty.") diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index fe56027dc..a34a1ca81 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -90,6 +90,38 @@ class CustomFieldTest(TestCase): # Delete the custom field cf.delete() + def test_multiple_select_field(self): + obj_type = ContentType.objects.get_for_model(Site) + + # Create a custom field + cf = CustomField( + type=CustomFieldTypeChoices.TYPE_SELECT, + name='my_field', + required=False, + choices=['Option A', 'Option B', 'Option C'], + multiple_selection=True + ) + cf.save() + cf.content_types.set([obj_type]) + + # Assign a value to the first Site + site = Site.objects.first() + site.custom_field_data[cf.name] = ['Option A', 'Option B'] + site.save() + + # Retrieve the stored value + site.refresh_from_db() + self.assertEqual(site.custom_field_data[cf.name], ['Option A', 'Option B']) + + # 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)) + + # Delete the custom field + cf.delete() + class CustomFieldManagerTest(TestCase): diff --git a/netbox/templates/inc/custom_fields_panel.html b/netbox/templates/inc/custom_fields_panel.html index d5f858f15..01e7e854b 100644 --- a/netbox/templates/inc/custom_fields_panel.html +++ b/netbox/templates/inc/custom_fields_panel.html @@ -15,6 +15,8 @@ {% elif field.type == 'url' and value %} {{ value|truncatechars:70 }} + {% elif field.type == 'select' and field.multiple_selection == True and value is not None %} + {{ value|join:", " }} {% elif value is not None %} {{ value }} {% elif field.required %}