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 %}