diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 45f8ac31f..3d03cbffb 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -13,6 +13,7 @@ class CustomFieldTypeChoices(ChoiceSet): TYPE_DATE = 'date' TYPE_URL = 'url' TYPE_SELECT = 'select' + TYPE_SELECT_MULTIPLE = 'select-multiple' CHOICES = ( (TYPE_TEXT, 'Text'), @@ -21,6 +22,7 @@ class CustomFieldTypeChoices(ChoiceSet): (TYPE_DATE, 'Date'), (TYPE_URL, 'URL'), (TYPE_SELECT, 'Selection'), + (TYPE_SELECT_MULTIPLE, 'Selection (multiple)'), ) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 932d07a4d..b29eea318 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -60,7 +60,7 @@ class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm): def _append_customfield_fields(self): # Append form fields - for cf in CustomField.objects.filter(content_types=self.obj_type): + for cf in CustomField.objects.filter(content_types=self.obj_type).exclude(type=CustomFieldTypeChoices.TYPE_SELECT_MULTIPLE): field_name = 'cf_{}'.format(cf.name) self.fields[field_name] = cf.to_form_field(for_csv_import=True) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index a69816d21..1e2709438 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, StaticSelect2, StaticSelect2Multiple, add_blank_choice from utilities.querysets import RestrictedQuerySet from utilities.validators import validate_regex @@ -121,7 +121,8 @@ class CustomField(models.Model): blank=True, null=True, help_text='Default value for the field (must be a JSON value). Encapsulate ' - 'strings with double quotes (e.g. "Foo").' + 'strings with double quotes (e.g. "Foo") and multiple selection ' + 'as ["choice1", "choice2"].' ) weight = models.PositiveSmallIntegerField( default=100, @@ -203,13 +204,13 @@ class CustomField(models.Model): }) # Choices can be set only on selection fields - if self.choices and self.type != CustomFieldTypeChoices.TYPE_SELECT: + if self.choices and self.type not in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_SELECT_MULTIPLE): raise ValidationError({ 'choices': "Choices may be set only for custom 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: + if self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_SELECT_MULTIPLE) and self.choices is not None and len(self.choices) < 2: raise ValidationError({ 'choices': "Selection fields must specify at least two choices." }) @@ -220,6 +221,12 @@ class CustomField(models.Model): 'default': f"The specified default value ({self.default}) is not listed as an available choice." }) + # A multiple selection field's defaults (if any) must be present in its available choices + if self.type == CustomFieldTypeChoices.TYPE_SELECT_MULTIPLE and self.default and not all(i in self.choices for i in self.default): + raise ValidationError({ + 'default': f"The specified default values ({self.default}) are not all listed as an available choice." + }) + 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. @@ -256,7 +263,7 @@ class CustomField(models.Model): field = forms.DateField(required=required, initial=initial, widget=DatePicker()) # Select - elif self.type == CustomFieldTypeChoices.TYPE_SELECT: + elif self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_SELECT_MULTIPLE): choices = [(c, c) for c in self.choices] default_choice = self.default if self.default in self.choices else None @@ -267,9 +274,15 @@ class CustomField(models.Model): if set_initial and default_choice: initial = default_choice - field_class = CSVChoiceField if for_csv_import else forms.ChoiceField + if for_csv_import: + if self.type == CustomFieldTypeChoices.TYPE_SELECT_MULTIPLE: + raise NotImplementedError('CSV fields do not support multiple select') + field_class = CSVChoiceField + else: + field_class = forms.MultipleChoiceField if self.type == CustomFieldTypeChoices.TYPE_SELECT_MULTIPLE else forms.ChoiceField + widget = StaticSelect2Multiple if self.type == CustomFieldTypeChoices.TYPE_SELECT_MULTIPLE else StaticSelect2 field = field_class( - choices=choices, required=required, initial=initial, widget=StaticSelect2() + choices=choices, required=required, initial=initial, widget=widget() ) # URL @@ -298,7 +311,7 @@ class CustomField(models.Model): """ Validate a value according to the field's type validation rules. """ - if value not in [None, '']: + if value not in [None, '', []]: # Validate text field if self.type == CustomFieldTypeChoices.TYPE_TEXT and self.validation_regex: @@ -335,5 +348,17 @@ class CustomField(models.Model): f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}" ) + # Validate selected choices + if self.type == CustomFieldTypeChoices.TYPE_SELECT_MULTIPLE: + if type(value) is not list: + raise ValidationError( + f"Invalid value ({value}). Select multiple values should be in format [\"choice1\"]" + ) + for val in value: + if val not in self.choices: + raise ValidationError( + f"Invalid choice ({val}). Available choices are: {', '.join(self.choices)}" + ) + elif self.required: raise ValidationError("Required field cannot be empty.") diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index bcc95467a..af297a7da 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -49,6 +49,11 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase): 'name': 'cf6', 'type': 'select', }, + { + 'content_types': ['dcim.site'], + 'name': 'cf7', + 'type': 'select-multiple', + }, ] bulk_update_data = { 'description': 'New description', diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 4f7a67676..258e37f30 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -91,6 +91,37 @@ class CustomFieldTest(TestCase): # Delete the custom field cf.delete() + def test_select_multiple_field(self): + obj_type = ContentType.objects.get_for_model(Site) + + # Create a custom field + cf = CustomField( + type=CustomFieldTypeChoices.TYPE_SELECT_MULTIPLE, + name='my_field', + required=False, + choices=['Option A', 'Option B', 'Option C'] + ) + 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 C'] + site.save() + + # Retrieve the stored value + site.refresh_from_db() + self.assertEqual(site.custom_field_data[cf.name], ['Option A', 'Option C']) + + # 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): @@ -142,6 +173,12 @@ class CustomFieldAPITest(APITestCase): cls.cf_select.save() cls.cf_select.content_types.set([content_type]) + # Select multiple custom field + cls.cf_select_multiple = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT_MULTIPLE, name='choice_multiple_field', choices=['Foo', 'Bar', 'Baz']) + cls.cf_select_multiple.default = ["Foo", "Baz"] + cls.cf_select_multiple.save() + cls.cf_select_multiple.content_types.set([content_type]) + # Create some sites cls.sites = ( Site(name='Site 1', slug='site-1'), @@ -157,6 +194,7 @@ class CustomFieldAPITest(APITestCase): cls.cf_date.name: '2020-01-02', cls.cf_url.name: 'http://example.com/2', cls.cf_select.name: 'Bar', + cls.cf_select_multiple.name: ['Bar'], } cls.sites[1].save() @@ -176,6 +214,7 @@ class CustomFieldAPITest(APITestCase): 'date_field': None, 'url_field': None, 'choice_field': None, + 'choice_multiple_field': None, }) def test_get_single_object_with_custom_field_data(self): @@ -194,6 +233,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field']) self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field']) self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field']) + self.assertEqual(response.data['custom_fields']['choice_multiple_field'], site2_cfvs['choice_multiple_field']) def test_create_single_object_with_defaults(self): """ @@ -217,6 +257,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], self.cf_date.default) self.assertEqual(response_cf['url_field'], self.cf_url.default) self.assertEqual(response_cf['choice_field'], self.cf_select.default) + self.assertEqual(response_cf['choice_multiple_field'], self.cf_select_multiple.default) # Validate database data site = Site.objects.get(pk=response.data['id']) @@ -226,6 +267,7 @@ class CustomFieldAPITest(APITestCase): 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['choice_field'], self.cf_select.default) + self.assertEqual(site.custom_field_data['choice_multiple_field'], self.cf_select_multiple.default) def test_create_single_object_with_values(self): """ @@ -241,6 +283,7 @@ class CustomFieldAPITest(APITestCase): 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', 'choice_field': 'Bar', + 'choice_multiple_field': ['Bar'], }, } url = reverse('dcim-api:site-list') @@ -258,6 +301,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], data_cf['date_field']) self.assertEqual(response_cf['url_field'], data_cf['url_field']) self.assertEqual(response_cf['choice_field'], data_cf['choice_field']) + self.assertEqual(response_cf['choice_multiple_field'], data_cf['choice_multiple_field']) # Validate database data site = Site.objects.get(pk=response.data['id']) @@ -266,7 +310,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field']) self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field']) self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field']) - self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field']) + self.assertEqual(site.custom_field_data['choice_multiple_field'], data_cf['choice_multiple_field']) def test_create_multiple_objects_with_defaults(self): """ @@ -304,6 +348,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], self.cf_date.default) self.assertEqual(response_cf['url_field'], self.cf_url.default) self.assertEqual(response_cf['choice_field'], self.cf_select.default) + self.assertEqual(response_cf['choice_multiple_field'], self.cf_select_multiple.default) # Validate database data site = Site.objects.get(pk=response.data[i]['id']) @@ -313,6 +358,7 @@ class CustomFieldAPITest(APITestCase): 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['choice_field'], self.cf_select.default) + self.assertEqual(site.custom_field_data['choice_multiple_field'], self.cf_select_multiple.default) def test_create_multiple_objects_with_values(self): """ @@ -325,6 +371,7 @@ class CustomFieldAPITest(APITestCase): 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', 'choice_field': 'Bar', + 'choice_multiple_field': ['Bar'], } data = ( { @@ -360,6 +407,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], custom_field_data['date_field']) self.assertEqual(response_cf['url_field'], custom_field_data['url_field']) self.assertEqual(response_cf['choice_field'], custom_field_data['choice_field']) + self.assertEqual(response_cf['choice_multiple_field'], custom_field_data['choice_multiple_field']) # Validate database data site = Site.objects.get(pk=response.data[i]['id']) @@ -369,6 +417,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field']) self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field']) self.assertEqual(site.custom_field_data['choice_field'], custom_field_data['choice_field']) + self.assertEqual(site.custom_field_data['choice_multiple_field'], custom_field_data['choice_multiple_field']) def test_update_single_object_with_values(self): """ @@ -397,6 +446,7 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], original_cfvs['date_field']) self.assertEqual(response_cf['url_field'], original_cfvs['url_field']) self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field']) + self.assertEqual(response_cf['choice_multiple_field'], original_cfvs['choice_multiple_field']) # Validate database data site.refresh_from_db() @@ -406,6 +456,7 @@ class CustomFieldAPITest(APITestCase): 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['choice_field'], original_cfvs['choice_field']) + self.assertEqual(site.custom_field_data['choice_multiple_field'], original_cfvs['choice_multiple_field']) def test_minimum_maximum_values_validation(self): url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) @@ -652,6 +703,11 @@ class CustomFieldFilterTest(TestCase): cf.save() cf.content_types.set([obj_type]) + # Selection multiple filtering + cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_URL, choices=['Foo', 'Bar', 'Baz']) + cf.save() + cf.content_types.set([obj_type]) + Site.objects.bulk_create([ Site(name='Site 1', slug='site-1', custom_field_data={ 'cf1': 100, @@ -662,6 +718,7 @@ class CustomFieldFilterTest(TestCase): 'cf6': 'http://foo.example.com/', 'cf7': 'http://foo.example.com/', 'cf8': 'Foo', + 'cf9': ['Foo'], }), Site(name='Site 2', slug='site-2', custom_field_data={ 'cf1': 200, @@ -672,6 +729,7 @@ class CustomFieldFilterTest(TestCase): 'cf6': 'http://bar.example.com/', 'cf7': 'http://bar.example.com/', 'cf8': 'Bar', + 'cf9': ['Bar'], }), Site(name='Site 3', slug='site-3', custom_field_data={ }), @@ -697,3 +755,6 @@ class CustomFieldFilterTest(TestCase): def test_filter_select(self): self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1) + + def test_filter_select_multiple(self): + self.assertEqual(self.filterset({'cf_cf9': 'Foo'}, self.queryset).qs.count(), 1)