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:
Harm Geerts 2021-03-23 14:56:35 +01:00
parent 0321ae0a7f
commit e66aad94a9
No known key found for this signature in database
GPG Key ID: 9B5DAC50E1850C10
5 changed files with 103 additions and 10 deletions

View File

@ -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)'),
)

View File

@ -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)

View File

@ -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.")

View File

@ -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',

View File

@ -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)