mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-27 02:48:38 -06:00
Add support for customfield static choices with multiple select
This resembles #2522 which was rejected before but with the revamp of customfields this use case is much easier to support. Effectively it only adds a new type that uses a MultipleChoiceField and the StaticSelect2Multiple widget. Note that CSV import is not yet supported because of #3637 and the multiple select custom fields are explicitly excluded.
This commit is contained in:
parent
0321ae0a7f
commit
e66aad94a9
@ -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)'),
|
||||
)
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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.")
|
||||
|
@ -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',
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user