^[A-Z]{3}$
will limit values to exactly three uppercase letters.'
)
)
- choices = ArrayField(
- base_field=models.CharField(max_length=100),
+ choice_set = models.ForeignKey(
+ to='CustomFieldChoiceSet',
+ on_delete=models.PROTECT,
+ related_name='choices_for',
blank=True,
- null=True,
- help_text=_('Comma-separated list of available choices (for selection fields)')
+ null=True
)
ui_visibility = models.CharField(
max_length=50,
@@ -181,8 +183,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
clone_fields = (
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
- 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
- 'ui_visibility', 'is_cloneable',
+ 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
+ 'choice_set', 'ui_visibility', 'is_cloneable',
)
class Meta:
@@ -208,6 +210,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
def search_type(self):
return SEARCH_TYPES.get(self.type)
+ @property
+ def choices(self):
+ if self.choice_set:
+ return self.choice_set.choices
+ return []
+
def populate_initial_data(self, content_types):
"""
Populate initial custom field data upon either a) the creation of a new CustomField, or
@@ -278,22 +286,18 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
'validation_regex': "Regular expression validation is supported only for text and URL fields"
})
- # Choices can be set only on selection fields
- if self.choices and self.type not in (
- CustomFieldTypeChoices.TYPE_SELECT,
- CustomFieldTypeChoices.TYPE_MULTISELECT
- ):
- raise ValidationError({
- 'choices': "Choices may be set only for custom selection fields."
- })
-
- # Selection fields must have at least one choice defined
+ # Choice set must be set on selection fields, and *only* on selection fields
if self.type in (
CustomFieldTypeChoices.TYPE_SELECT,
CustomFieldTypeChoices.TYPE_MULTISELECT
- ) and not self.choices:
+ ):
+ if not self.choice_set:
+ raise ValidationError({
+ 'choice_set': "Selection fields must specify a set of choices."
+ })
+ elif self.choice_set:
raise ValidationError({
- 'choices': "Selection fields must specify at least one choice."
+ 'choice_set': "Choices may be set only on selection fields."
})
# A selection field's default (if any) must be present in its available choices
@@ -627,3 +631,52 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
elif self.required:
raise ValidationError("Required field cannot be empty.")
+
+
+class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
+ """
+ Represents a set of choices available for choice and multi-choice custom fields.
+ """
+ name = models.CharField(
+ max_length=100,
+ unique=True
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
+ extra_choices = ArrayField(
+ base_field=models.CharField(max_length=100),
+ help_text=_('List of field choices')
+ )
+ order_alphabetically = models.BooleanField(
+ default=False,
+ help_text=_('Choices are automatically ordered alphabetically on save')
+ )
+
+ clone_fields = ('extra_choices', 'order_alphabetically')
+
+ class Meta:
+ ordering = ('name',)
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('extras:customfieldchoiceset', args=[self.pk])
+
+ @property
+ def choices(self):
+ return self.extra_choices
+
+ @property
+ def choices_count(self):
+ return len(self.choices)
+
+ def save(self, *args, **kwargs):
+
+ # Sort choices if alphabetical ordering is enforced
+ if self.order_alphabetically:
+ self.extra_choices = sorted(self.choices)
+
+ return super().save(*args, **kwargs)
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index 6cb363c01..e5e722398 100644
--- a/netbox/extras/tables/tables.py
+++ b/netbox/extras/tables/tables.py
@@ -2,6 +2,7 @@ import json
import django_tables2 as tables
from django.conf import settings
+from django.utils.translation import gettext as _
from extras.models import *
from netbox.tables import NetBoxTable, columns
@@ -12,6 +13,7 @@ __all__ = (
'ConfigContextTable',
'ConfigRevisionTable',
'ConfigTemplateTable',
+ 'CustomFieldChoiceSetTable',
'CustomFieldTable',
'CustomLinkTable',
'ExportTemplateTable',
@@ -64,6 +66,11 @@ class CustomFieldTable(NetBoxTable):
required = columns.BooleanColumn()
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
description = columns.MarkdownColumn()
+ choices = columns.ArrayColumn(
+ max_items=10,
+ orderable=False,
+ verbose_name=_('Choices')
+ )
is_cloneable = columns.BooleanColumn()
class Meta(NetBoxTable.Meta):
@@ -76,6 +83,33 @@ class CustomFieldTable(NetBoxTable):
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
+class CustomFieldChoiceSetTable(NetBoxTable):
+ name = tables.Column(
+ linkify=True
+ )
+ choices = columns.ArrayColumn(
+ max_items=10,
+ accessor=tables.A('extra_choices'),
+ orderable=False,
+ verbose_name=_('Choices')
+ )
+ choice_count = tables.TemplateColumn(
+ accessor=tables.A('extra_choices'),
+ template_code='{{ value|length }}',
+ orderable=False,
+ verbose_name=_('Count')
+ )
+ order_alphabetically = columns.BooleanColumn()
+
+ class Meta(NetBoxTable.Meta):
+ model = CustomFieldChoiceSet
+ fields = (
+ 'pk', 'id', 'name', 'description', 'choice_count', 'choices', 'order_alphabetically', 'created',
+ 'last_updated',
+ )
+ default_columns = ('pk', 'name', 'choice_count', 'description')
+
+
class CustomLinkTable(NetBoxTable):
name = tables.Column(
linkify=True
diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py
index cbf3b8529..922b45240 100644
--- a/netbox/extras/tests/test_api.py
+++ b/netbox/extras/tests/test_api.py
@@ -98,8 +98,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
{
'content_types': ['dcim.site'],
'name': 'cf6',
- 'type': 'select',
- 'choices': ['A', 'B', 'C']
+ 'type': 'text',
},
]
bulk_update_data = {
@@ -134,6 +133,42 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
cf.content_types.add(site_ct)
+class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
+ model = CustomFieldChoiceSet
+ brief_fields = ['choices_count', 'display', 'id', 'name', 'url']
+ create_data = [
+ {
+ 'name': 'Choice Set 4',
+ 'extra_choices': ['4A', '4B', '4C'],
+ },
+ {
+ 'name': 'Choice Set 5',
+ 'extra_choices': ['5A', '5B', '5C'],
+ },
+ {
+ 'name': 'Choice Set 6',
+ 'extra_choices': ['6A', '6B', '6C'],
+ },
+ ]
+ bulk_update_data = {
+ 'description': 'New description',
+ }
+ update_data = {
+ 'name': 'Choice Set X',
+ 'extra_choices': ['X1', 'X2', 'X3'],
+ 'description': 'New description',
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+ choice_sets = (
+ CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']),
+ CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']),
+ CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']),
+ )
+ CustomFieldChoiceSet.objects.bulk_create(choice_sets)
+
+
class CustomLinkTest(APIViewTestCases.APIViewTestCase):
model = CustomLink
brief_fields = ['display', 'id', 'name', 'url']
diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py
index e0be8c3bd..9ebbeef5c 100644
--- a/netbox/extras/tests/test_changelog.py
+++ b/netbox/extras/tests/test_changelog.py
@@ -5,7 +5,7 @@ from rest_framework import status
from dcim.choices import SiteStatusChoices
from dcim.models import Site
from extras.choices import *
-from extras.models import CustomField, ObjectChange, Tag
+from extras.models import CustomField, CustomFieldChoiceSet, ObjectChange, Tag
from utilities.testing import APITestCase
from utilities.testing.utils import create_tags, post_data
from utilities.testing.views import ModelViewTestCase
@@ -16,12 +16,16 @@ class ChangeLogViewTest(ModelViewTestCase):
@classmethod
def setUpTestData(cls):
+ choice_set = CustomFieldChoiceSet.objects.create(
+ name='Custom Field Choice Set 1',
+ extra_choices=['Bar', 'Foo']
+ )
# Create a custom field on the Site model
ct = ContentType.objects.get_for_model(Site)
cf = CustomField(
type=CustomFieldTypeChoices.TYPE_TEXT,
- name='my_field',
+ name='cf1',
required=False
)
cf.save()
@@ -30,9 +34,9 @@ class ChangeLogViewTest(ModelViewTestCase):
# Create a select custom field on the Site model
cf_select = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
- name='my_field_select',
+ name='cf2',
required=False,
- choices=['Bar', 'Foo']
+ choice_set=choice_set
)
cf_select.save()
cf_select.content_types.set([ct])
@@ -43,8 +47,8 @@ class ChangeLogViewTest(ModelViewTestCase):
'name': 'Site 1',
'slug': 'site-1',
'status': SiteStatusChoices.STATUS_ACTIVE,
- 'cf_my_field': 'ABC',
- 'cf_my_field_select': 'Bar',
+ 'cf_cf1': 'ABC',
+ 'cf_cf2': 'Bar',
'tags': [tag.pk for tag in tags],
}
@@ -65,8 +69,8 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(oc.prechange_data, None)
- self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field'])
- self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
+ self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1'])
+ self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
def test_update_object(self):
@@ -79,8 +83,8 @@ class ChangeLogViewTest(ModelViewTestCase):
'name': 'Site X',
'slug': 'site-x',
'status': SiteStatusChoices.STATUS_PLANNED,
- 'cf_my_field': 'DEF',
- 'cf_my_field_select': 'Foo',
+ 'cf_cf1': 'DEF',
+ 'cf_cf2': 'Foo',
'tags': [tags[2].pk],
}
@@ -102,8 +106,8 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(oc.prechange_data['name'], 'Site 1')
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
- self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field'])
- self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select'])
+ self.assertEqual(oc.postchange_data['custom_fields']['cf1'], form_data['cf_cf1'])
+ self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
def test_delete_object(self):
@@ -111,8 +115,8 @@ class ChangeLogViewTest(ModelViewTestCase):
name='Site 1',
slug='site-1',
custom_field_data={
- 'my_field': 'ABC',
- 'my_field_select': 'Bar'
+ 'cf1': 'ABC',
+ 'cf2': 'Bar'
}
)
site.save()
@@ -131,8 +135,8 @@ class ChangeLogViewTest(ModelViewTestCase):
self.assertEqual(oc.changed_object, None)
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
- self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC')
- self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar')
+ self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC')
+ self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar')
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data, None)
@@ -213,18 +217,22 @@ class ChangeLogAPITest(APITestCase):
ct = ContentType.objects.get_for_model(Site)
cf = CustomField(
type=CustomFieldTypeChoices.TYPE_TEXT,
- name='my_field',
+ name='cf1',
required=False
)
cf.save()
cf.content_types.set([ct])
# Create a select custom field on the Site model
+ choice_set = CustomFieldChoiceSet.objects.create(
+ name='Choice Set 1',
+ extra_choices=['Bar', 'Foo']
+ )
cf_select = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
- name='my_field_select',
+ name='cf2',
required=False,
- choices=['Bar', 'Foo']
+ choice_set=choice_set
)
cf_select.save()
cf_select.content_types.set([ct])
@@ -242,8 +250,8 @@ class ChangeLogAPITest(APITestCase):
'name': 'Site 1',
'slug': 'site-1',
'custom_fields': {
- 'my_field': 'ABC',
- 'my_field_select': 'Bar',
+ 'cf1': 'ABC',
+ 'cf2': 'Bar',
},
'tags': [
{'name': 'Tag 1'},
@@ -276,8 +284,8 @@ class ChangeLogAPITest(APITestCase):
'name': 'Site X',
'slug': 'site-x',
'custom_fields': {
- 'my_field': 'DEF',
- 'my_field_select': 'Foo',
+ 'cf1': 'DEF',
+ 'cf2': 'Foo',
},
'tags': [
{'name': 'Tag 3'}
@@ -305,8 +313,8 @@ class ChangeLogAPITest(APITestCase):
name='Site 1',
slug='site-1',
custom_field_data={
- 'my_field': 'ABC',
- 'my_field_select': 'Bar'
+ 'cf1': 'ABC',
+ 'cf2': 'Bar'
}
)
site.save()
@@ -323,8 +331,8 @@ class ChangeLogAPITest(APITestCase):
self.assertEqual(oc.changed_object, None)
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
- self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC')
- self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar')
+ self.assertEqual(oc.prechange_data['custom_fields']['cf1'], 'ABC')
+ self.assertEqual(oc.prechange_data['custom_fields']['cf2'], 'Bar')
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
self.assertEqual(oc.postchange_data, None)
diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py
index 3fd0dc83e..3b802a0f2 100644
--- a/netbox/extras/tests/test_customfields.py
+++ b/netbox/extras/tests/test_customfields.py
@@ -10,7 +10,7 @@ from dcim.filtersets import SiteFilterSet
from dcim.forms import SiteImportForm
from dcim.models import Manufacturer, Rack, Site
from extras.choices import *
-from extras.models import CustomField
+from extras.models import CustomField, CustomFieldChoiceSet
from ipam.models import VLAN
from utilities.testing import APITestCase, TestCase
from virtualization.models import VirtualMachine
@@ -272,12 +272,18 @@ class CustomFieldTest(TestCase):
CHOICES = ('Option A', 'Option B', 'Option C')
value = CHOICES[1]
+ # Create a set of custom field choices
+ choice_set = CustomFieldChoiceSet.objects.create(
+ name='Custom Field Choice Set 1',
+ extra_choices=CHOICES
+ )
+
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='select_field',
type=CustomFieldTypeChoices.TYPE_SELECT,
required=False,
- choices=CHOICES
+ choice_set=choice_set
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
@@ -299,12 +305,18 @@ class CustomFieldTest(TestCase):
CHOICES = ['Option A', 'Option B', 'Option C']
value = [CHOICES[1], CHOICES[2]]
+ # Create a set of custom field choices
+ choice_set = CustomFieldChoiceSet.objects.create(
+ name='Custom Field Choice Set 1',
+ extra_choices=CHOICES
+ )
+
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='multiselect_field',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
required=False,
- choices=CHOICES
+ choice_set=choice_set
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
@@ -438,6 +450,12 @@ class CustomFieldAPITest(APITestCase):
)
VLAN.objects.bulk_create(vlans)
+ # Create a set of custom field choices
+ choice_set = CustomFieldChoiceSet.objects.create(
+ name='Custom Field Choice Set 1',
+ extra_choices=('Foo', 'Bar', 'Baz')
+ )
+
custom_fields = (
CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'),
@@ -452,17 +470,13 @@ class CustomFieldAPITest(APITestCase):
type=CustomFieldTypeChoices.TYPE_SELECT,
name='select_field',
default='Foo',
- choices=(
- 'Foo', 'Bar', 'Baz'
- )
+ choice_set=choice_set
),
CustomField(
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
name='multiselect_field',
default=['Foo'],
- choices=(
- 'Foo', 'Bar', 'Baz'
- )
+ choice_set=choice_set
),
CustomField(
type=CustomFieldTypeChoices.TYPE_OBJECT,
@@ -1024,6 +1038,12 @@ class CustomFieldImportTest(TestCase):
@classmethod
def setUpTestData(cls):
+ # Create a set of custom field choices
+ choice_set = CustomFieldChoiceSet.objects.create(
+ name='Custom Field Choice Set 1',
+ extra_choices=('Choice A', 'Choice B', 'Choice C')
+ )
+
custom_fields = (
CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT),
@@ -1034,12 +1054,8 @@ class CustomFieldImportTest(TestCase):
CustomField(name='datetime', type=CustomFieldTypeChoices.TYPE_DATETIME),
CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
CustomField(name='json', type=CustomFieldTypeChoices.TYPE_JSON),
- CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
- 'Choice A', 'Choice B', 'Choice C',
- ]),
- CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[
- 'Choice A', 'Choice B', 'Choice C',
- ]),
+ CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choice_set=choice_set),
+ CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choice_set=choice_set),
)
for cf in custom_fields:
cf.save()
@@ -1203,6 +1219,11 @@ class CustomFieldModelFilterTest(TestCase):
Manufacturer(name='Manufacturer 4', slug='manufacturer-4'),
))
+ choice_set = CustomFieldChoiceSet.objects.create(
+ name='Custom Field Choice Set 1',
+ extra_choices=['A', 'B', 'C', 'X']
+ )
+
# Integer filtering
cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER)
cf.save()
@@ -1263,7 +1284,7 @@ class CustomFieldModelFilterTest(TestCase):
cf = CustomField(
name='cf9',
type=CustomFieldTypeChoices.TYPE_SELECT,
- choices=['Foo', 'Bar', 'Baz']
+ choice_set=choice_set
)
cf.save()
cf.content_types.set([obj_type])
@@ -1272,7 +1293,7 @@ class CustomFieldModelFilterTest(TestCase):
cf = CustomField(
name='cf10',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
- choices=['A', 'B', 'C', 'X']
+ choice_set=choice_set
)
cf.save()
cf.content_types.set([obj_type])
@@ -1305,7 +1326,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf6': '2016-06-26',
'cf7': 'http://a.example.com',
'cf8': 'http://a.example.com',
- 'cf9': 'Foo',
+ 'cf9': 'A',
'cf10': ['A', 'X'],
'cf11': manufacturers[0].pk,
'cf12': [manufacturers[0].pk, manufacturers[3].pk],
@@ -1319,7 +1340,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf6': '2016-06-27',
'cf7': 'http://b.example.com',
'cf8': 'http://b.example.com',
- 'cf9': 'Bar',
+ 'cf9': 'B',
'cf10': ['B', 'X'],
'cf11': manufacturers[1].pk,
'cf12': [manufacturers[1].pk, manufacturers[3].pk],
@@ -1333,7 +1354,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf6': '2016-06-28',
'cf7': 'http://c.example.com',
'cf8': 'http://c.example.com',
- 'cf9': 'Baz',
+ 'cf9': 'C',
'cf10': ['C', 'X'],
'cf11': manufacturers[2].pk,
'cf12': [manufacturers[2].pk, manufacturers[3].pk],
@@ -1399,7 +1420,7 @@ class CustomFieldModelFilterTest(TestCase):
self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
def test_filter_select(self):
- self.assertEqual(self.filterset({'cf_cf9': ['Foo', 'Bar']}, self.queryset).qs.count(), 2)
+ self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
def test_filter_multiselect(self):
self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2)
diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py
index b4b216244..c558a0467 100644
--- a/netbox/extras/tests/test_filtersets.py
+++ b/netbox/extras/tests/test_filtersets.py
@@ -27,7 +27,11 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
@classmethod
def setUpTestData(cls):
- content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
+ choice_sets = (
+ CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']),
+ CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']),
+ )
+ CustomFieldChoiceSet.objects.bulk_create(choice_sets)
custom_fields = (
CustomField(
@@ -54,11 +58,31 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
),
+ CustomField(
+ name='Custom Field 4',
+ type=CustomFieldTypeChoices.TYPE_SELECT,
+ required=False,
+ weight=400,
+ filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
+ ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN,
+ choice_set=choice_sets[0]
+ ),
+ CustomField(
+ name='Custom Field 5',
+ type=CustomFieldTypeChoices.TYPE_MULTISELECT,
+ required=False,
+ weight=500,
+ filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED,
+ ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN,
+ choice_set=choice_sets[1]
+ ),
)
CustomField.objects.bulk_create(custom_fields)
- custom_fields[0].content_types.add(content_types[0])
- custom_fields[1].content_types.add(content_types[1])
- custom_fields[2].content_types.add(content_types[2])
+ custom_fields[0].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'site'))
+ custom_fields[1].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'rack'))
+ custom_fields[2].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
+ custom_fields[3].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
+ custom_fields[4].content_types.add(ContentType.objects.get_by_natural_key('dcim', 'device'))
def test_name(self):
params = {'name': ['Custom Field 1', 'Custom Field 2']}
@@ -67,7 +91,7 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
def test_content_types(self):
params = {'content_types': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
- params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
+ params = {'content_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_required(self):
@@ -86,6 +110,34 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests):
params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ def test_choice_set(self):
+ params = {'choice_set': ['Choice Set 1', 'Choice Set 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'choice_set_id': CustomFieldChoiceSet.objects.values_list('pk', flat=True)}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class CustomFieldChoiceSetTestCase(TestCase, BaseFilterSetTests):
+ queryset = CustomFieldChoiceSet.objects.all()
+ filterset = CustomFieldChoiceSetFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+ choice_sets = (
+ CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['A', 'B', 'C']),
+ CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['D', 'E', 'F']),
+ CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['G', 'H', 'I']),
+ )
+ CustomFieldChoiceSet.objects.bulk_create(choice_sets)
+
+ def test_name(self):
+ params = {'name': ['Choice Set 1', 'Choice Set 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_choice(self):
+ params = {'choice': ['A', 'D']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class WebhookTestCase(TestCase, BaseFilterSetTests):
queryset = Webhook.objects.all()
diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py
index cc3625c7c..9d6054b86 100644
--- a/netbox/extras/tests/test_forms.py
+++ b/netbox/extras/tests/test_forms.py
@@ -5,7 +5,7 @@ from dcim.forms import SiteForm
from dcim.models import Site
from extras.choices import CustomFieldTypeChoices
from extras.forms import SavedFilterForm
-from extras.models import CustomField
+from extras.models import CustomField, CustomFieldChoiceSet
class CustomFieldModelFormTest(TestCase):
@@ -13,7 +13,10 @@ class CustomFieldModelFormTest(TestCase):
@classmethod
def setUpTestData(cls):
obj_type = ContentType.objects.get_for_model(Site)
- CHOICES = ('A', 'B', 'C')
+ choice_set = CustomFieldChoiceSet.objects.create(
+ name='Custom Field Choice Set 1',
+ extra_choices=('A', 'B', 'C')
+ )
cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)
cf_text.content_types.set([obj_type])
@@ -42,13 +45,17 @@ class CustomFieldModelFormTest(TestCase):
cf_json = CustomField.objects.create(name='json', type=CustomFieldTypeChoices.TYPE_JSON)
cf_json.content_types.set([obj_type])
- cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES)
+ cf_select = CustomField.objects.create(
+ name='select',
+ type=CustomFieldTypeChoices.TYPE_SELECT,
+ choice_set=choice_set
+ )
cf_select.content_types.set([obj_type])
cf_multiselect = CustomField.objects.create(
name='multiselect',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
- choices=CHOICES
+ choice_set=choice_set
)
cf_multiselect.content_types.set([obj_type])
diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py
index 57efc5be7..acfdcf1e3 100644
--- a/netbox/extras/tests/test_views.py
+++ b/netbox/extras/tests/test_views.py
@@ -21,6 +21,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
+ CustomFieldChoiceSet.objects.create(
+ name='Choice Set 1',
+ extra_choices=('A', 'B', 'C')
+ )
+
custom_fields = (
CustomField(name='field1', label='Field 1', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='field2', label='Field 2', type=CustomFieldTypeChoices.TYPE_TEXT),
@@ -44,10 +49,10 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
- 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility',
+ 'name,label,type,content_types,object_type,weight,search_weight,filter_logic,choice_set,validation_minimum,validation_maximum,validation_regex,ui_visibility',
'field4,Field 4,text,dcim.site,,100,1000,exact,,,,[a-z]{3},read-write',
'field5,Field 5,integer,dcim.site,,100,2000,exact,,1,100,,read-write',
- 'field6,Field 6,select,dcim.site,,100,3000,exact,"A,B,C",,,,read-write',
+ 'field6,Field 6,select,dcim.site,,100,3000,exact,Choice Set 1,,,,read-write',
'field7,Field 7,object,dcim.site,dcim.region,100,4000,exact,,,,,read-write',
)
@@ -64,6 +69,43 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
+class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = CustomFieldChoiceSet
+
+ @classmethod
+ def setUpTestData(cls):
+
+ choice_sets = (
+ CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']),
+ CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']),
+ CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']),
+ )
+ CustomFieldChoiceSet.objects.bulk_create(choice_sets)
+
+ cls.form_data = {
+ 'name': 'Choice Set X',
+ 'extra_choices': 'X1,X2,X3,X4,X5',
+ }
+
+ cls.csv_data = (
+ 'name,extra_choices',
+ 'Choice Set 4,"4A,4B,4C,4D,4E"',
+ 'Choice Set 5,"5A,5B,5C,5D,5E"',
+ 'Choice Set 6,"6A,6B,6C,6D,6E"',
+ )
+
+ cls.csv_update_data = (
+ 'id,extra_choices',
+ f'{choice_sets[0].pk},"1X,1Y,1Z"',
+ f'{choice_sets[1].pk},"2X,2Y,2Z"',
+ f'{choice_sets[2].pk},"3X,3Y,3Z"',
+ )
+
+ cls.bulk_edit_data = {
+ 'description': 'New description',
+ }
+
+
class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomLink
diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py
index 086537b99..fd95186e4 100644
--- a/netbox/extras/urls.py
+++ b/netbox/extras/urls.py
@@ -15,6 +15,14 @@ urlpatterns = [
path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'),
path('custom-fields/Search Weight | @@ -60,33 +81,6 @@ | UI Visibility | {{ object.get_ui_visibility_display }} |
---|---|---|---|
Cloneable | -{% checkmark object.is_cloneable %} | -
Default Value | -{{ object.default }} | -
---|---|
Choices | -- {% if object.choices %} - {{ object.choices|join:", " }} - {% else %} - {{ ''|placeholder }} - {% endif %} - | -
Name | +{{ object.name }} | +
---|---|
Description | +{{ object.description|markdown|placeholder }} | +
Choices | +{{ object.choices|length }} | +
Order Alphabetically | +{% checkmark object.order_alphabetically %} | +
Used by | +
+
|
+
{{ choice }} | +