#13241: Add support for custom field choice labels

This commit is contained in:
Jeremy Stretch 2023-07-26 16:00:03 -04:00
parent 5b570d6922
commit c60aec2725
15 changed files with 102 additions and 101 deletions

View File

@ -131,6 +131,10 @@ class CustomFieldSerializer(ValidatedModelSerializer):
class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
base_choices = ChoiceField(
choices=CustomFieldChoiceSetBaseChoices,
required=False
)
class Meta:
model = CustomFieldChoiceSet

View File

@ -82,8 +82,8 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
if page is not None:
data = [
{
'id': c[0],
'display': c[1],
'value': c[0],
'label': c[1],
} for c in page
]
return self.get_paginated_response(data)

View File

@ -128,7 +128,7 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet):
def filter_by_choice(self, queryset, name, value):
# TODO: Support case-insensitive matching
return queryset.filter(extra_choices__overlap=value)
return queryset.filter(extra_choices__has_any_keys=value)
class CustomLinkFilterSet(BaseFilterSet):

View File

@ -68,11 +68,6 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
required=False,
help_text=_('The classification of entry')
)
extra_choices = SimpleArrayField(
base_field=forms.CharField(),
required=False,
help_text=_('Comma-separated list of field choices')
)
class Meta:
model = CustomFieldChoiceSet

View File

@ -19,7 +19,7 @@ from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, JSONField, SlugField,
)
from utilities.forms.widgets import ArrayWidget
from utilities.forms.widgets import ChoicesWidget
from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -85,19 +85,14 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
extra_choices = forms.CharField(
widget=ArrayWidget(),
required=False,
help_text=_('Enter one choice per line.')
extra_choices = forms.JSONField(
required=False
)
class Meta:
model = CustomFieldChoiceSet
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically')
def clean_extra_choices(self):
return self.cleaned_data['extra_choices'].splitlines()
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(

View File

@ -19,7 +19,7 @@ def create_choice_sets(apps, schema_editor):
for cf in choice_fields:
choiceset = CustomFieldChoiceSet.objects.create(
name=f'{cf.name} Choices',
extra_choices=cf.choices
extra_choices=dict(zip(cf.choices, cf.choices)) # Convert list to key:val dict
)
cf.choice_set = choiceset
@ -43,7 +43,7 @@ class Migration(migrations.Migration):
('name', models.CharField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
('base_choices', models.CharField(blank=True, max_length=50)),
('extra_choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), blank=True, null=True, size=None)),
('extra_choices', models.JSONField(blank=True, default=dict, null=True)),
('order_alphabetically', models.BooleanField(default=False)),
],
options={

View File

@ -6,7 +6,6 @@ import django_filters
from django import forms
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.validators import RegexValidator, ValidationError
from django.db import models
from django.urls import reverse
@ -658,9 +657,8 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
blank=True,
help_text=_('Base set of predefined choices (optional)')
)
extra_choices = ArrayField(
base_field=models.CharField(max_length=100),
help_text=_('List of field choices'),
extra_choices = models.JSONField(
default=dict,
blank=True,
null=True
)
@ -686,25 +684,27 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
Returns a concatenation of the base and extra choices.
"""
if not hasattr(self, '_choices'):
self._choices = []
self._choices = {}
if self.base_choices:
self._choices.extend(CHOICE_SETS.get(self.base_choices))
self._choices.update(dict(CHOICE_SETS.get(self.base_choices)))
if self.extra_choices:
self._choices.extend([(k, k) for k in self.extra_choices])
return self._choices
def clean(self):
if not self.base_choices and not self.extra_choices:
raise ValidationError(_("Must define base or extra choices."))
self._choices.update(self.extra_choices)
if self.order_alphabetically:
self._choices = dict(sorted(self._choices.items()))
return list(self._choices.items())
@property
def choices_count(self):
return len(self.choices)
def clean(self):
if not self.base_choices and not self.extra_choices:
raise ValidationError(_("Must define base or extra choices."))
def save(self, *args, **kwargs):
# Sort choices if alphabetical ordering is enforced
if self.order_alphabetically:
self.extra_choices = sorted(self.extra_choices)
self.extra_choices = dict(sorted(self.extra_choices.items()))
return super().save(*args, **kwargs)

View File

@ -68,11 +68,9 @@ class CustomFieldTable(NetBoxTable):
verbose_name="UI visibility"
)
description = columns.MarkdownColumn()
choices = columns.ArrayColumn(
choices = columns.ChoiceSetColumn(
max_items=10,
func=lambda x: x[1],
orderable=False,
verbose_name=_('Choices')
orderable=False
)
is_cloneable = columns.BooleanColumn()
@ -91,16 +89,13 @@ class CustomFieldChoiceSetTable(NetBoxTable):
linkify=True
)
base_choices = columns.ChoiceFieldColumn()
extra_choices = columns.ArrayColumn(
extra_choices = tables.TemplateColumn(
template_code="""{% for k, v in value.items %}{{ v }}{% if not forloop.last %}, {% endif %}{% endfor %}"""
)
choices = columns.ChoiceSetColumn(
max_items=10,
orderable=False
)
choices = columns.ArrayColumn(
max_items=10,
func=lambda x: x[1],
orderable=False,
verbose_name=_('Choices')
)
choice_count = tables.TemplateColumn(
accessor=tables.A('extra_choices'),
template_code='{{ value|length }}',

View File

@ -17,8 +17,8 @@ class ChangeLogViewTest(ModelViewTestCase):
@classmethod
def setUpTestData(cls):
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=['Bar', 'Foo']
name='Choice Set 1',
extra_choices={'bar': 'Bar', 'foo': 'Foo'}
)
# Create a custom field on the Site model
@ -48,7 +48,7 @@ class ChangeLogViewTest(ModelViewTestCase):
'slug': 'site-1',
'status': SiteStatusChoices.STATUS_ACTIVE,
'cf_cf1': 'ABC',
'cf_cf2': 'Bar',
'cf_cf2': 'bar',
'tags': [tag.pk for tag in tags],
}
@ -84,7 +84,7 @@ class ChangeLogViewTest(ModelViewTestCase):
'slug': 'site-x',
'status': SiteStatusChoices.STATUS_PLANNED,
'cf_cf1': 'DEF',
'cf_cf2': 'Foo',
'cf_cf2': 'foo',
'tags': [tags[2].pk],
}
@ -226,7 +226,7 @@ class ChangeLogAPITest(APITestCase):
# Create a select custom field on the Site model
choice_set = CustomFieldChoiceSet.objects.create(
name='Choice Set 1',
extra_choices=['Bar', 'Foo']
extra_choices={'bar': 'Bar', 'foo': 'Foo'}
)
cf_select = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
@ -251,7 +251,7 @@ class ChangeLogAPITest(APITestCase):
'slug': 'site-1',
'custom_fields': {
'cf1': 'ABC',
'cf2': 'Bar',
'cf2': 'bar',
},
'tags': [
{'name': 'Tag 1'},
@ -285,7 +285,7 @@ class ChangeLogAPITest(APITestCase):
'slug': 'site-x',
'custom_fields': {
'cf1': 'DEF',
'cf2': 'Foo',
'cf2': 'foo',
},
'tags': [
{'name': 'Tag 3'}

View File

@ -269,8 +269,12 @@ class CustomFieldTest(TestCase):
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_select_field(self):
CHOICES = ('Option A', 'Option B', 'Option C')
value = CHOICES[1]
CHOICES = {
'a': 'Option A',
'b': 'Option B',
'c': 'Option C',
}
value = 'a'
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
@ -302,8 +306,12 @@ class CustomFieldTest(TestCase):
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_multiselect_field(self):
CHOICES = ['Option A', 'Option B', 'Option C']
value = [CHOICES[1], CHOICES[2]]
CHOICES = {
'a': 'Option A',
'b': 'Option B',
'c': 'Option C',
}
value = ['a', 'b']
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
@ -453,7 +461,7 @@ class CustomFieldAPITest(APITestCase):
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=('Foo', 'Bar', 'Baz')
extra_choices={'foo': 'Foo', 'bar': 'Bar', 'baz': 'Baz'}
)
custom_fields = (
@ -469,13 +477,13 @@ class CustomFieldAPITest(APITestCase):
CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
name='select_field',
default='Foo',
default='foo',
choice_set=choice_set
),
CustomField(
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
name='multiselect_field',
default=['Foo'],
default=['foo'],
choice_set=choice_set
),
CustomField(
@ -514,8 +522,8 @@ class CustomFieldAPITest(APITestCase):
custom_fields[6].name: '2020-01-02 12:00:00',
custom_fields[7].name: 'http://example.com/2',
custom_fields[8].name: '{"foo": 1, "bar": 2}',
custom_fields[9].name: 'Bar',
custom_fields[10].name: ['Bar', 'Baz'],
custom_fields[9].name: 'bar',
custom_fields[10].name: ['bar', 'baz'],
custom_fields[11].name: vlans[1].pk,
custom_fields[12].name: [vlans[2].pk, vlans[3].pk],
}
@ -671,8 +679,8 @@ class CustomFieldAPITest(APITestCase):
'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 2}',
'select_field': 'Bar',
'multiselect_field': ['Bar', 'Baz'],
'select_field': 'bar',
'multiselect_field': ['bar', 'baz'],
'object_field': VLAN.objects.get(vid=2).pk,
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
},
@ -799,8 +807,8 @@ class CustomFieldAPITest(APITestCase):
'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 2}',
'select_field': 'Bar',
'multiselect_field': ['Bar', 'Baz'],
'select_field': 'bar',
'multiselect_field': ['bar', 'baz'],
'object_field': VLAN.objects.get(vid=2).pk,
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
}
@ -1041,7 +1049,7 @@ class CustomFieldImportTest(TestCase):
# 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')
extra_choices={'a': 'Option A', 'b': 'Option B', 'c': 'Option C'}
)
custom_fields = (
@ -1067,8 +1075,8 @@ class CustomFieldImportTest(TestCase):
"""
data = (
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date', 'cf_datetime', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', '2020-01-01 12:00:00', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', '2020-01-02 12:00:00', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', '2020-01-01 12:00:00', 'http://example.com/1', '{"foo": 123}', 'a', '"a,b"'),
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', '2020-01-02 12:00:00', 'http://example.com/2', '{"bar": 456}', 'b', '"b,c"'),
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', '', ''),
)
csv_data = '\n'.join(','.join(row) for row in data)
@ -1089,8 +1097,8 @@ class CustomFieldImportTest(TestCase):
self.assertEqual(site1.cf['datetime'].isoformat(), '2020-01-01T12:00:00+00:00')
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
self.assertEqual(site1.custom_field_data['select'], 'Choice A')
self.assertEqual(site1.custom_field_data['multiselect'], ['Choice A', 'Choice B'])
self.assertEqual(site1.custom_field_data['select'], 'a')
self.assertEqual(site1.custom_field_data['multiselect'], ['a', 'b'])
# Validate data for site 2
site2 = Site.objects.get(name='Site 2')
@ -1104,8 +1112,8 @@ class CustomFieldImportTest(TestCase):
self.assertEqual(site2.cf['datetime'].isoformat(), '2020-01-02T12:00:00+00:00')
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
self.assertEqual(site2.custom_field_data['select'], 'Choice B')
self.assertEqual(site2.custom_field_data['multiselect'], ['Choice B', 'Choice C'])
self.assertEqual(site2.custom_field_data['select'], 'b')
self.assertEqual(site2.custom_field_data['multiselect'], ['b', 'c'])
# No custom field data should be set for site 3
site3 = Site.objects.get(name='Site 3')
@ -1221,7 +1229,7 @@ class CustomFieldModelFilterTest(TestCase):
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=['A', 'B', 'C', 'X']
extra_choices={'A': 'A', 'B': 'B', 'C': 'C', 'X': 'X'}
)
# Integer filtering

View File

@ -14,8 +14,8 @@ class CustomFieldModelFormTest(TestCase):
def setUpTestData(cls):
obj_type = ContentType.objects.get_for_model(Site)
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=('A', 'B', 'C')
name='Choice Set 1',
extra_choices={'a': 'A', 'b': 'B', 'c': 'C'}
)
cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)

View File

@ -1,3 +1,4 @@
import json
import urllib.parse
import uuid
@ -23,7 +24,7 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
site_ct = ContentType.objects.get_for_model(Site)
CustomFieldChoiceSet.objects.create(
name='Choice Set 1',
extra_choices=('A', 'B', 'C')
extra_choices={'A': 'A', 'B': 'B', 'C': 'C'}
)
custom_fields = (
@ -76,29 +77,38 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
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(
name='Choice Set 1',
extra_choices={'A1': 'Choice 1', 'A2': 'Choice 2', 'A3': 'Choice 3'}
),
CustomFieldChoiceSet(
name='Choice Set 2',
extra_choices={'B1': 'Choice 1', 'B2': 'Choice 2', 'B3': 'Choice 3'}
),
CustomFieldChoiceSet(
name='Choice Set 3',
extra_choices={'C1': 'Choice 1', 'C2': 'Choice 2', 'C3': 'Choice 3'}
),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
cls.form_data = {
'name': 'Choice Set X',
'extra_choices': 'X1,X2,X3,X4,X5',
'extra_choices': json.dumps({'X1': 'Choice 1', 'X2': 'Choice 2', 'X3': 'Choice 3'})
}
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"',
'Choice Set 4,"{""D1"": ""Choice 1"", ""D2"": ""Choice 2"", ""D3"": ""Choice 3""}"',
'Choice Set 5,"{""E1"": ""Choice 1"", ""E2"": ""Choice 2"", ""E3"": ""Choice 3""}"',
'Choice Set 6,"{""F1"": ""Choice 1"", ""F2"": ""Choice 2"", ""F3"": ""Choice 3""}"',
)
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"',
f'{choice_sets[0].pk},"{{""a"": ""A"", ""b"": ""B"", ""c"": ""C""}}"',
f'{choice_sets[1].pk},"{{""a"": ""A"", ""b"": ""B"", ""c"": ""C""}}"',
f'{choice_sets[2].pk},"{{""a"": ""A"", ""b"": ""B"", ""c"": ""C""}}"',
)
cls.bulk_edit_data = {

View File

@ -9,8 +9,8 @@ from django.db.models import DateField, DateTimeField
from django.template import Context, Template
from django.urls import reverse
from django.utils.dateparse import parse_date
from django.utils.html import escape
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django_tables2.columns import library
from django_tables2.utils import Accessor
@ -21,9 +21,9 @@ from utilities.utils import content_type_identifier, content_type_name, get_view
__all__ = (
'ActionsColumn',
'ArrayColumn',
'BooleanColumn',
'ChoiceFieldColumn',
'ChoiceSetColumn',
'ColorColumn',
'ColoredLabelColumn',
'ContentTypeColumn',
@ -594,27 +594,21 @@ class MarkdownColumn(tables.TemplateColumn):
return value
class ArrayColumn(tables.Column):
"""
List array items as a comma-separated list.
"""
def __init__(self, *args, max_items=None, func=str, **kwargs):
class ChoiceSetColumn(tables.Column):
def __init__(self, *args, max_items=None, **kwargs):
self.max_items = max_items
self.func = func
super().__init__(*args, **kwargs)
def render(self, value):
omitted_count = 0
value = [v[1] for v in value]
# Limit the returned items to the specified maximum number (if any)
if self.max_items:
omitted_count = len(value) - self.max_items
value = value[:self.max_items - 1]
# Apply custom processing function (if any) per item
if self.func:
value = [self.func(v) for v in value]
# Annotate omitted items (if applicable)
if omitted_count > 0:
value.append(f'({omitted_count} more)')

View File

@ -55,7 +55,7 @@
<th>Label</th>
</tr>
</thead>
{% for value, label in object.choices|slice:":100" %}
{% for value, label in object.choices.items %}
<tr>
<td>{{ value }}</td>
<td>{{ label }}</td>

View File

@ -1,7 +1,7 @@
from django import forms
__all__ = (
'ArrayWidget',
'ChoicesWidget',
'ClearableFileInput',
'MarkdownWidget',
'NumberWithOptions',
@ -46,11 +46,11 @@ class SlugWidget(forms.TextInput):
template_name = 'widgets/sluginput.html'
class ArrayWidget(forms.Textarea):
class ChoicesWidget(forms.Textarea):
"""
Render each item of an array on a new line within a textarea for easy editing/
Render each key-value pair of a dictionary on a new line within a textarea for easy editing.
"""
def format_value(self, value):
if value is None or not len(value):
if not value:
return None
return '\n'.join(value)
return '\n'.join([f'{k},{v}' for k, v in value.items()])