Skip to content

Commit 7421e5f

Browse files
committed
Fixes #8317: Fix CSV import of multi-select custom field values
1 parent 0b2a43c commit 7421e5f

File tree

4 files changed

+76
-15
lines changed

4 files changed

+76
-15
lines changed

docs/release-notes/version-3.1.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
* [#8301](https://github.com/netbox-community/netbox/issues/8301) - Fix delete button for various object children views
1717
* [#8305](https://github.com/netbox-community/netbox/issues/8305) - Fix assignment of custom field data to FHRP groups via UI
1818
* [#8306](https://github.com/netbox-community/netbox/issues/8306) - Redirect user to previous page after login
19+
* [#8317](https://github.com/netbox-community/netbox/issues/8317) - Fix CSV import of multi-select custom field values
1920

2021
---
2122

netbox/extras/models/customfields.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
from netbox.models import ChangeLoggedModel
1717
from utilities import filters
1818
from utilities.forms import (
19-
CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
19+
CSVChoiceField, CSVMultipleChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect,
20+
add_blank_choice,
2021
)
2122
from utilities.querysets import RestrictedQuerySet
2223
from utilities.validators import validate_regex
@@ -287,7 +288,7 @@ def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=
287288
choices=choices, required=required, initial=initial, widget=StaticSelect()
288289
)
289290
else:
290-
field_class = CSVChoiceField if for_csv_import else forms.MultipleChoiceField
291+
field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
291292
field = field_class(
292293
choices=choices, required=required, initial=initial, widget=StaticSelectMultiple()
293294
)

netbox/extras/tests/test_customfields.py

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,14 @@ def test_simple_fields(self):
122122

123123
def test_select_field(self):
124124
obj_type = ContentType.objects.get_for_model(Site)
125+
choices = ['Option A', 'Option B', 'Option C']
125126

126127
# Create a custom field
127128
cf = CustomField(
128129
type=CustomFieldTypeChoices.TYPE_SELECT,
129130
name='my_field',
130131
required=False,
131-
choices=['Option A', 'Option B', 'Option C']
132+
choices=choices
132133
)
133134
cf.save()
134135
cf.content_types.set([obj_type])
@@ -138,12 +139,47 @@ def test_select_field(self):
138139
self.assertIsNone(site.custom_field_data[cf.name])
139140

140141
# Assign a value to the first Site
141-
site.custom_field_data[cf.name] = 'Option A'
142+
site.custom_field_data[cf.name] = choices[0]
142143
site.save()
143144

144145
# Retrieve the stored value
145146
site.refresh_from_db()
146-
self.assertEqual(site.custom_field_data[cf.name], 'Option A')
147+
self.assertEqual(site.custom_field_data[cf.name], choices[0])
148+
149+
# Delete the stored value
150+
site.custom_field_data.pop(cf.name)
151+
site.save()
152+
site.refresh_from_db()
153+
self.assertIsNone(site.custom_field_data.get(cf.name))
154+
155+
# Delete the custom field
156+
cf.delete()
157+
158+
def test_multiselect_field(self):
159+
obj_type = ContentType.objects.get_for_model(Site)
160+
choices = ['Option A', 'Option B', 'Option C']
161+
162+
# Create a custom field
163+
cf = CustomField(
164+
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
165+
name='my_field',
166+
required=False,
167+
choices=choices
168+
)
169+
cf.save()
170+
cf.content_types.set([obj_type])
171+
172+
# Check that the field has a null initial value
173+
site = Site.objects.first()
174+
self.assertIsNone(site.custom_field_data[cf.name])
175+
176+
# Assign a value to the first Site
177+
site.custom_field_data[cf.name] = [choices[0], choices[1]]
178+
site.save()
179+
180+
# Retrieve the stored value
181+
site.refresh_from_db()
182+
self.assertEqual(site.custom_field_data[cf.name], [choices[0], choices[1]])
147183

148184
# Delete the stored value
149185
site.custom_field_data.pop(cf.name)
@@ -597,6 +633,9 @@ def setUpTestData(cls):
597633
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
598634
'Choice A', 'Choice B', 'Choice C',
599635
]),
636+
CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[
637+
'Choice A', 'Choice B', 'Choice C',
638+
]),
600639
)
601640
for cf in custom_fields:
602641
cf.save()
@@ -607,19 +646,20 @@ def test_import(self):
607646
Import a Site in CSV format, including a value for each CustomField.
608647
"""
609648
data = (
610-
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select'),
611-
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A'),
612-
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B'),
613-
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', ''),
649+
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
650+
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
651+
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
652+
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''),
614653
)
615654
csv_data = '\n'.join(','.join(row) for row in data)
616655

617656
response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data})
618657
self.assertEqual(response.status_code, 200)
658+
self.assertEqual(Site.objects.count(), 3)
619659

620660
# Validate data for site 1
621661
site1 = Site.objects.get(name='Site 1')
622-
self.assertEqual(len(site1.custom_field_data), 8)
662+
self.assertEqual(len(site1.custom_field_data), 9)
623663
self.assertEqual(site1.custom_field_data['text'], 'ABC')
624664
self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
625665
self.assertEqual(site1.custom_field_data['integer'], 123)
@@ -628,10 +668,11 @@ def test_import(self):
628668
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
629669
self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
630670
self.assertEqual(site1.custom_field_data['select'], 'Choice A')
671+
self.assertEqual(site1.custom_field_data['multiselect'], ['Choice A', 'Choice B'])
631672

632673
# Validate data for site 2
633674
site2 = Site.objects.get(name='Site 2')
634-
self.assertEqual(len(site2.custom_field_data), 8)
675+
self.assertEqual(len(site2.custom_field_data), 9)
635676
self.assertEqual(site2.custom_field_data['text'], 'DEF')
636677
self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
637678
self.assertEqual(site2.custom_field_data['integer'], 456)
@@ -640,6 +681,7 @@ def test_import(self):
640681
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
641682
self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
642683
self.assertEqual(site2.custom_field_data['select'], 'Choice B')
684+
self.assertEqual(site2.custom_field_data['multiselect'], ['Choice B', 'Choice C'])
643685

644686
# No custom field data should be set for site 3
645687
site3 = Site.objects.get(name='Site 3')

netbox/utilities/forms/fields.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
'CSVDataField',
3232
'CSVFileField',
3333
'CSVModelChoiceField',
34+
'CSVMultipleChoiceField',
3435
'CSVMultipleContentTypeField',
3536
'CSVTypedChoiceField',
3637
'DynamicModelChoiceField',
@@ -263,17 +264,33 @@ def validate(self, value):
263264
return value
264265

265266

266-
class CSVChoiceField(forms.ChoiceField):
267-
"""
268-
Invert the provided set of choices to take the human-friendly label as input, and return the database value.
269-
"""
267+
class CSVChoicesMixin:
270268
STATIC_CHOICES = True
271269

272270
def __init__(self, *, choices=(), **kwargs):
273271
super().__init__(choices=choices, **kwargs)
274272
self.choices = unpack_grouped_choices(choices)
275273

276274

275+
class CSVChoiceField(CSVChoicesMixin, forms.ChoiceField):
276+
"""
277+
A CSV field which accepts a single selection value.
278+
"""
279+
pass
280+
281+
282+
class CSVMultipleChoiceField(CSVChoicesMixin, forms.MultipleChoiceField):
283+
"""
284+
A CSV field which accepts multiple selection values.
285+
"""
286+
def to_python(self, value):
287+
if not value:
288+
return []
289+
if not isinstance(value, str):
290+
raise forms.ValidationError(f"Invalid value for a multiple choice field: {value}")
291+
return value.split(',')
292+
293+
277294
class CSVTypedChoiceField(forms.TypedChoiceField):
278295
STATIC_CHOICES = True
279296

0 commit comments

Comments
 (0)