Change extra_choices back to a nested ArrayField to preserve choice ordering

This commit is contained in:
Jeremy Stretch 2023-07-27 13:07:38 -04:00
parent d8da99f225
commit 766b57670e
14 changed files with 105 additions and 51 deletions

View File

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

View File

@ -68,6 +68,11 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
required=False, required=False,
help_text=_('The base set of predefined choices to use (if any)') help_text=_('The base set of predefined choices to use (if any)')
) )
extra_choices = SimpleArrayField(
base_field=forms.CharField(),
required=False,
help_text=_('Comma-separated list of field choices')
)
class Meta: class Meta:
model = CustomFieldChoiceSet model = CustomFieldChoiceSet

View File

@ -19,6 +19,7 @@ from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, JSONField, SlugField, DynamicModelMultipleChoiceField, JSONField, SlugField,
) )
from utilities.forms.widgets import ChoicesWidget
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -84,14 +85,24 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm): class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
extra_choices = forms.JSONField( extra_choices = forms.CharField(
required=False widget=ChoicesWidget(),
) )
class Meta: class Meta:
model = CustomFieldChoiceSet model = CustomFieldChoiceSet
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically') fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically')
def clean_extra_choices(self):
data = []
for line in self.cleaned_data['extra_choices'].splitlines():
try:
value, label = line.split(',', maxsplit=1)
except ValueError:
value, label = line, line
data.append((value, label))
return data
class CustomLinkForm(BootstrapMixin, forms.ModelForm): class CustomLinkForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField( content_types = ContentTypeMultipleChoiceField(

View File

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

View File

@ -6,6 +6,7 @@ import django_filters
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.validators import RegexValidator, ValidationError from django.core.validators import RegexValidator, ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -657,8 +658,11 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
blank=True, blank=True,
help_text=_('Base set of predefined choices (optional)') help_text=_('Base set of predefined choices (optional)')
) )
extra_choices = models.JSONField( extra_choices = ArrayField(
default=dict, ArrayField(
base_field=models.CharField(max_length=100),
size=2
),
blank=True, blank=True,
null=True null=True
) )
@ -684,14 +688,14 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
Returns a concatenation of the base and extra choices. Returns a concatenation of the base and extra choices.
""" """
if not hasattr(self, '_choices'): if not hasattr(self, '_choices'):
self._choices = {} self._choices = []
if self.base_choices: if self.base_choices:
self._choices.update(dict(CHOICE_SETS.get(self.base_choices))) self._choices.extend(CHOICE_SETS.get(self.base_choices))
if self.extra_choices: if self.extra_choices:
self._choices.update(self.extra_choices) self._choices.extend(self.extra_choices)
if self.order_alphabetically: if self.order_alphabetically:
self._choices = dict(sorted(self._choices.items())) self._choices = sorted(self._choices, key=lambda x: x[0])
return list(self._choices.items()) return self._choices
@property @property
def choices_count(self): def choices_count(self):
@ -705,6 +709,6 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
# Sort choices if alphabetical ordering is enforced # Sort choices if alphabetical ordering is enforced
if self.order_alphabetically: if self.order_alphabetically:
self.extra_choices = dict(sorted(self.extra_choices.items())) self.extra_choices = sorted(self.extra_choices, key=lambda x: x[0])
return super().save(*args, **kwargs) return super().save(*args, **kwargs)

View File

@ -66,6 +66,9 @@ class CustomFieldTable(NetBoxTable):
required = columns.BooleanColumn() required = columns.BooleanColumn()
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility") ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
description = columns.MarkdownColumn() description = columns.MarkdownColumn()
choice_set = tables.Column(
linkify=True
)
choices = columns.ChoicesColumn( choices = columns.ChoicesColumn(
max_items=10, max_items=10,
orderable=False orderable=False
@ -76,8 +79,8 @@ class CustomFieldTable(NetBoxTable):
model = CustomField model = CustomField
fields = ( fields = (
'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description', 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description',
'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'weight', 'choices', 'created', 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'weight', 'choice_set', 'choices',
'last_updated', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')

View File

@ -139,15 +139,27 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
create_data = [ create_data = [
{ {
'name': 'Choice Set 4', 'name': 'Choice Set 4',
'extra_choices': ['4A', '4B', '4C'], 'extra_choices': [
['4A', 'Choice 1'],
['4B', 'Choice 2'],
['4C', 'Choice 3'],
],
}, },
{ {
'name': 'Choice Set 5', 'name': 'Choice Set 5',
'extra_choices': ['5A', '5B', '5C'], 'extra_choices': [
['5A', 'Choice 1'],
['5B', 'Choice 2'],
['5C', 'Choice 3'],
],
}, },
{ {
'name': 'Choice Set 6', 'name': 'Choice Set 6',
'extra_choices': ['6A', '6B', '6C'], 'extra_choices': [
['6A', 'Choice 1'],
['6B', 'Choice 2'],
['6C', 'Choice 3'],
],
}, },
] ]
bulk_update_data = { bulk_update_data = {
@ -155,7 +167,11 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
} }
update_data = { update_data = {
'name': 'Choice Set X', 'name': 'Choice Set X',
'extra_choices': ['X1', 'X2', 'X3'], 'extra_choices': [
['X1', 'Choice 1'],
['X2', 'Choice 2'],
['X3', 'Choice 3'],
],
'description': 'New description', 'description': 'New description',
} }

View File

@ -18,7 +18,7 @@ class ChangeLogViewTest(ModelViewTestCase):
def setUpTestData(cls): def setUpTestData(cls):
choice_set = CustomFieldChoiceSet.objects.create( choice_set = CustomFieldChoiceSet.objects.create(
name='Choice Set 1', name='Choice Set 1',
extra_choices={'bar': 'Bar', 'foo': 'Foo'} extra_choices=(('foo', 'Foo'), ('bar', 'Bar'))
) )
# Create a custom field on the Site model # Create a custom field on the Site model
@ -226,7 +226,7 @@ class ChangeLogAPITest(APITestCase):
# Create a select custom field on the Site model # Create a select custom field on the Site model
choice_set = CustomFieldChoiceSet.objects.create( choice_set = CustomFieldChoiceSet.objects.create(
name='Choice Set 1', name='Choice Set 1',
extra_choices={'bar': 'Bar', 'foo': 'Foo'} extra_choices=(('foo', 'Foo'), ('bar', 'Bar'))
) )
cf_select = CustomField( cf_select = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT, type=CustomFieldTypeChoices.TYPE_SELECT,

View File

@ -269,11 +269,11 @@ class CustomFieldTest(TestCase):
self.assertIsNone(instance.custom_field_data.get(cf.name)) self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_select_field(self): def test_select_field(self):
CHOICES = { CHOICES = (
'a': 'Option A', ('a', 'Option A'),
'b': 'Option B', ('b', 'Option B'),
'c': 'Option C', ('c', 'Option C'),
} )
value = 'a' value = 'a'
# Create a set of custom field choices # Create a set of custom field choices
@ -306,11 +306,11 @@ class CustomFieldTest(TestCase):
self.assertIsNone(instance.custom_field_data.get(cf.name)) self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_multiselect_field(self): def test_multiselect_field(self):
CHOICES = { CHOICES = (
'a': 'Option A', ('a', 'Option A'),
'b': 'Option B', ('b', 'Option B'),
'c': 'Option C', ('c', 'Option C'),
} )
value = ['a', 'b'] value = ['a', 'b']
# Create a set of custom field choices # Create a set of custom field choices
@ -461,7 +461,7 @@ class CustomFieldAPITest(APITestCase):
# Create a set of custom field choices # Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create( choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1', name='Custom Field Choice Set 1',
extra_choices={'foo': 'Foo', 'bar': 'Bar', 'baz': 'Baz'} extra_choices=(('foo', 'Foo'), ('bar', 'Bar'), ('baz', 'Baz'))
) )
custom_fields = ( custom_fields = (
@ -1049,7 +1049,11 @@ class CustomFieldImportTest(TestCase):
# Create a set of custom field choices # Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create( choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1', name='Custom Field Choice Set 1',
extra_choices={'a': 'Option A', 'b': 'Option B', 'c': 'Option C'} extra_choices=(
('a', 'Option A'),
('b', 'Option B'),
('c', 'Option C'),
)
) )
custom_fields = ( custom_fields = (
@ -1229,7 +1233,7 @@ class CustomFieldModelFilterTest(TestCase):
choice_set = CustomFieldChoiceSet.objects.create( choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1', name='Custom Field Choice Set 1',
extra_choices={'A': 'A', 'B': 'B', 'C': 'C', 'X': 'X'} extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'), ('x', 'X'))
) )
# Integer filtering # Integer filtering

View File

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

View File

@ -24,7 +24,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
site_ct = ContentType.objects.get_for_model(Site) site_ct = ContentType.objects.get_for_model(Site)
CustomFieldChoiceSet.objects.create( CustomFieldChoiceSet.objects.create(
name='Choice Set 1', name='Choice Set 1',
extra_choices={'A': 'A', 'B': 'B', 'C': 'C'} extra_choices=(
('A', 'A'),
('B', 'B'),
('C', 'C'),
)
) )
custom_fields = ( custom_fields = (
@ -79,36 +83,36 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
choice_sets = ( choice_sets = (
CustomFieldChoiceSet( CustomFieldChoiceSet(
name='Choice Set 1', name='Choice Set 1',
extra_choices={'A1': 'Choice 1', 'A2': 'Choice 2', 'A3': 'Choice 3'} extra_choices=(('A1', 'Choice 1'), ('A2', 'Choice 2'), ('A3', 'Choice 3'))
), ),
CustomFieldChoiceSet( CustomFieldChoiceSet(
name='Choice Set 2', name='Choice Set 2',
extra_choices={'B1': 'Choice 1', 'B2': 'Choice 2', 'B3': 'Choice 3'} extra_choices=(('B1', 'Choice 1'), ('B2', 'Choice 2'), ('B3', 'Choice 3'))
), ),
CustomFieldChoiceSet( CustomFieldChoiceSet(
name='Choice Set 3', name='Choice Set 3',
extra_choices={'C1': 'Choice 1', 'C2': 'Choice 2', 'C3': 'Choice 3'} extra_choices=(('C1', 'Choice 1'), ('C2', 'Choice 2'), ('C3', 'Choice 3'))
), ),
) )
CustomFieldChoiceSet.objects.bulk_create(choice_sets) CustomFieldChoiceSet.objects.bulk_create(choice_sets)
cls.form_data = { cls.form_data = {
'name': 'Choice Set X', 'name': 'Choice Set X',
'extra_choices': json.dumps({'X1': 'Choice 1', 'X2': 'Choice 2', 'X3': 'Choice 3'}) 'extra_choices': '\n'.join(['X1,Choice 1', 'X2,Choice 2', 'X3,Choice 3'])
} }
cls.csv_data = ( cls.csv_data = (
'name,extra_choices', 'name,extra_choices',
'Choice Set 4,"{""D1"": ""Choice 1"", ""D2"": ""Choice 2"", ""D3"": ""Choice 3""}"', 'Choice Set 4,"D1,D2,D3"',
'Choice Set 5,"{""E1"": ""Choice 1"", ""E2"": ""Choice 2"", ""E3"": ""Choice 3""}"', 'Choice Set 5,"E1,E2,E3"',
'Choice Set 6,"{""F1"": ""Choice 1"", ""F2"": ""Choice 2"", ""F3"": ""Choice 3""}"', 'Choice Set 6,"F1,F2,F3"',
) )
cls.csv_update_data = ( cls.csv_update_data = (
'id,extra_choices', 'id,extra_choices',
f'{choice_sets[0].pk},"{{""a"": ""A"", ""b"": ""B"", ""c"": ""C""}}"', f'{choice_sets[0].pk},"A,B,C"',
f'{choice_sets[1].pk},"{{""a"": ""A"", ""b"": ""B"", ""c"": ""C""}}"', f'{choice_sets[1].pk},"A,B,C"',
f'{choice_sets[2].pk},"{{""a"": ""A"", ""b"": ""B"", ""c"": ""C""}}"', f'{choice_sets[2].pk},"A,B,C"',
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {

View File

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

View File

@ -64,4 +64,6 @@ class ChoicesWidget(forms.Textarea):
def format_value(self, value): def format_value(self, value):
if not value: if not value:
return None return None
return '\n'.join([f'{k},{v}' for k, v in value.items()]) if type(value) is list:
return '\n'.join([f'{k},{v}' for k, v in value])
return value

View File

@ -129,13 +129,18 @@ class ModelTestCase(TestCase):
model_dict[key] = str(value) model_dict[key] = str(value)
else: else:
field = instance._meta.get_field(key)
# Convert ArrayFields to CSV strings # Convert ArrayFields to CSV strings
if type(instance._meta.get_field(key)) is ArrayField: if type(field) is ArrayField:
if type(field.base_field) is ArrayField:
# Handle nested arrays (e.g. choice sets)
model_dict[key] = '\n'.join([f'{k},{v}' for k, v in value])
else:
model_dict[key] = ','.join([str(v) for v in value]) model_dict[key] = ','.join([str(v) for v in value])
# JSON # JSON
if type(instance._meta.get_field(key)) is JSONField and value is not None: if type(field) is JSONField and value is not None:
model_dict[key] = json.dumps(value) model_dict[key] = json.dumps(value)
return model_dict return model_dict