Skip to content

Commit db3b450

Browse files
Merge pull request #3885 from hSaria/568-csv-import-cf
Fixes #568: CSV import/export of custom fields
2 parents 943c644 + 8ec0ad9 commit db3b450

File tree

11 files changed

+114
-25
lines changed

11 files changed

+114
-25
lines changed

docs/release-notes/version-2.7.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
## Enhancements
1212

13+
* [#568](https://github.com/netbox-community/netbox/issues/568) - Allow custom fields to be imported and exported using CSV
1314
* [#3310](https://github.com/netbox-community/netbox/issues/3310) - Pre-select site/rack for B side when creating a new cable
1415
* [#3338](https://github.com/netbox-community/netbox/issues/3338) - Include circuit terminations in API representation of circuits
1516
* [#3509](https://github.com/netbox-community/netbox/issues/3509) - Add IP address variables for custom scripts

netbox/circuits/forms.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ class Meta:
4646
}
4747

4848

49-
class ProviderCSVForm(forms.ModelForm):
49+
class ProviderCSVForm(CustomFieldForm):
5050
slug = SlugField()
5151

5252
class Meta:
@@ -188,7 +188,7 @@ class Meta:
188188
}
189189

190190

191-
class CircuitCSVForm(forms.ModelForm):
191+
class CircuitCSVForm(CustomFieldForm):
192192
provider = forms.ModelChoiceField(
193193
queryset=Provider.objects.all(),
194194
to_field_name='name',

netbox/dcim/forms.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ class Meta:
263263
}
264264

265265

266-
class SiteCSVForm(forms.ModelForm):
266+
class SiteCSVForm(CustomFieldForm):
267267
status = CSVChoiceField(
268268
choices=SiteStatusChoices,
269269
required=False,
@@ -504,7 +504,7 @@ class Meta:
504504
}
505505

506506

507-
class RackCSVForm(forms.ModelForm):
507+
class RackCSVForm(CustomFieldForm):
508508
site = forms.ModelChoiceField(
509509
queryset=Site.objects.all(),
510510
to_field_name='name',
@@ -1724,7 +1724,7 @@ def __init__(self, *args, **kwargs):
17241724
self.initial['rack'] = self.instance.parent_bay.device.rack_id
17251725

17261726

1727-
class BaseDeviceCSVForm(forms.ModelForm):
1727+
class BaseDeviceCSVForm(CustomFieldForm):
17281728
device_role = forms.ModelChoiceField(
17291729
queryset=DeviceRole.objects.all(),
17301730
to_field_name='name',
@@ -4286,7 +4286,7 @@ def __init__(self, *args, **kwargs):
42864286
self.initial['site'] = self.instance.power_panel.site
42874287

42884288

4289-
class PowerFeedCSVForm(forms.ModelForm):
4289+
class PowerFeedCSVForm(CustomFieldForm):
42904290
site = forms.ModelChoiceField(
42914291
queryset=Site.objects.all(),
42924292
to_field_name='name',

netbox/extras/forms.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
from tenancy.models import Tenant, TenantGroup
1111
from utilities.forms import (
1212
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
13-
CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField,
14-
SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
13+
CustomFieldChoiceField, CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField,
14+
LaxURLField, JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
1515
)
1616
from .choices import *
1717
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
@@ -61,7 +61,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
6161

6262
# Select
6363
elif cf.type == CustomFieldTypeChoices.TYPE_SELECT:
64-
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
64+
choices = [(cfc.pk, cfc.value) for cfc in cf.choices.all()]
6565
if not cf.required or bulk_edit or filterable_only:
6666
choices = [(None, '---------')] + choices
6767
# Check for a default choice
@@ -71,7 +71,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
7171
default_choice = cf.choices.get(value=initial).pk
7272
except ObjectDoesNotExist:
7373
pass
74-
field = forms.TypedChoiceField(
74+
field = CustomFieldChoiceField(
7575
choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2()
7676
)
7777

netbox/extras/tests/test_customfields.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
from datetime import date
22

33
from django.contrib.contenttypes.models import ContentType
4-
from django.test import TestCase
4+
from django.test import Client, TestCase
55
from django.urls import reverse
66
from rest_framework import status
77

88
from dcim.models import Site
99
from extras.choices import *
1010
from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
11-
from utilities.testing import APITestCase
11+
from utilities.testing import APITestCase, create_test_user
1212
from virtualization.models import VirtualMachine
1313

1414

@@ -364,3 +364,62 @@ def test_list_cfc(self):
364364
self.assertEqual(self.cf_choice_1.pk, response.data[self.cf_1.name][self.cf_choice_1.value])
365365
self.assertEqual(self.cf_choice_2.pk, response.data[self.cf_1.name][self.cf_choice_2.value])
366366
self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value])
367+
368+
369+
class CustomFieldCSV(TestCase):
370+
def setUp(self):
371+
super().setUp()
372+
373+
user = create_test_user(
374+
permissions=[
375+
'dcim.view_site',
376+
'dcim.add_site',
377+
]
378+
)
379+
self.client = Client()
380+
self.client.force_login(user)
381+
382+
obj_type = ContentType.objects.get_for_model(Site)
383+
384+
self.cf_text = CustomField.objects.create(name="text", type=CustomFieldTypeChoices.TYPE_TEXT)
385+
self.cf_text.obj_type.set([obj_type])
386+
self.cf_text.save()
387+
388+
self.cf_choice = CustomField.objects.create(name="choice", type=CustomFieldTypeChoices.TYPE_SELECT)
389+
self.cf_choice.obj_type.set([obj_type])
390+
self.cf_choice.save()
391+
392+
self.cf_choice_1 = CustomFieldChoice.objects.create(field=self.cf_choice, value="cf_field_1")
393+
self.cf_choice_2 = CustomFieldChoice.objects.create(field=self.cf_choice, value="cf_field_2")
394+
self.cf_choice_3 = CustomFieldChoice.objects.create(field=self.cf_choice, value="cf_field_3")
395+
396+
def test_import(self):
397+
"""
398+
Import a site with custom fields
399+
"""
400+
csv_data = (
401+
"name,slug,cf_text,cf_choice",
402+
"Site 1,site-1,something,cf_field_1",
403+
)
404+
405+
response = self.client.post(reverse('dcim:site_import'), {'csv': '\n'.join(csv_data)})
406+
self.assertEqual(response.status_code, 200)
407+
408+
site1_custom_fields = Site.objects.get(name='Site 1').get_custom_fields()
409+
self.assertEqual(len(site1_custom_fields), 2)
410+
self.assertEqual(site1_custom_fields[self.cf_text], 'something')
411+
self.assertEqual(site1_custom_fields[self.cf_choice], self.cf_choice_1)
412+
413+
def test_import_invalid_choice(self):
414+
"""
415+
Import a site with an invalid choice
416+
"""
417+
csv_data = (
418+
"name,slug,cf_choice",
419+
"Site 2,site-2,cf_field_4",
420+
)
421+
422+
response = self.client.post(reverse('dcim:site_import'), {'csv': '\n'.join(csv_data)})
423+
self.assertEqual(response.status_code, 200)
424+
425+
self.assertFalse(len(Site.objects.filter(name="Site 2")), 0)

netbox/ipam/forms.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class Meta:
4949
}
5050

5151

52-
class VRFCSVForm(forms.ModelForm):
52+
class VRFCSVForm(CustomFieldForm):
5353
tenant = forms.ModelChoiceField(
5454
queryset=Tenant.objects.all(),
5555
required=False,
@@ -166,7 +166,7 @@ class Meta:
166166
}
167167

168168

169-
class AggregateCSVForm(forms.ModelForm):
169+
class AggregateCSVForm(CustomFieldForm):
170170
rir = forms.ModelChoiceField(
171171
queryset=RIR.objects.all(),
172172
to_field_name='name',
@@ -341,7 +341,7 @@ def __init__(self, *args, **kwargs):
341341
self.fields['vrf'].empty_label = 'Global'
342342

343343

344-
class PrefixCSVForm(forms.ModelForm):
344+
class PrefixCSVForm(CustomFieldForm):
345345
vrf = FlexibleModelChoiceField(
346346
queryset=VRF.objects.all(),
347347
to_field_name='rd',
@@ -771,7 +771,7 @@ def __init__(self, *args, **kwargs):
771771
self.fields['vrf'].empty_label = 'Global'
772772

773773

774-
class IPAddressCSVForm(forms.ModelForm):
774+
class IPAddressCSVForm(CustomFieldForm):
775775
vrf = FlexibleModelChoiceField(
776776
queryset=VRF.objects.all(),
777777
to_field_name='rd',
@@ -1135,7 +1135,7 @@ class Meta:
11351135
}
11361136

11371137

1138-
class VLANCSVForm(forms.ModelForm):
1138+
class VLANCSVForm(CustomFieldForm):
11391139
site = forms.ModelChoiceField(
11401140
queryset=Site.objects.all(),
11411141
required=False,

netbox/secrets/forms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ def clean(self):
116116
})
117117

118118

119-
class SecretCSVForm(forms.ModelForm):
119+
class SecretCSVForm(CustomFieldForm):
120120
device = FlexibleModelChoiceField(
121121
queryset=Device.objects.all(),
122122
to_field_name='name',

netbox/tenancy/forms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ class Meta:
5757
}
5858

5959

60-
class TenantCSVForm(forms.ModelForm):
60+
class TenantCSVForm(CustomFieldForm):
6161
slug = SlugField()
6262
group = forms.ModelChoiceField(
6363
queryset=TenantGroup.objects.all(),

netbox/utilities/forms.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,23 @@ def clean(self, value):
442442
return self.choice_values[value]
443443

444444

445+
class CustomFieldChoiceField(forms.TypedChoiceField):
446+
"""
447+
Accept human-friendly label as input, and return the database value. If the label is not matched, the normal,
448+
value-based input is assumed.
449+
"""
450+
451+
def __init__(self, choices, *args, **kwargs):
452+
super().__init__(choices=choices, *args, **kwargs)
453+
self.choice_values = {label: value for value, label in unpack_grouped_choices(choices)}
454+
455+
def clean(self, value):
456+
# Check if the value is actually a label
457+
if value in self.choice_values:
458+
return self.choice_values[value]
459+
return super().clean(value)
460+
461+
445462
class ExpandableNameField(forms.CharField):
446463
"""
447464
A field which allows for numeric range expansion

netbox/utilities/views.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,15 +88,27 @@ def queryset_to_csv(self):
8888
Export the queryset of objects as comma-separated value (CSV), using the model's to_csv() method.
8989
"""
9090
csv_data = []
91+
custom_fields = []
9192

9293
# Start with the column headers
93-
headers = ','.join(self.queryset.model.csv_headers)
94-
csv_data.append(headers)
94+
headers = self.queryset.model.csv_headers.copy()
95+
96+
# Add custom field headers, if any
97+
if hasattr(self.queryset.model, 'get_custom_fields'):
98+
for custom_field in self.queryset.model().get_custom_fields():
99+
headers.append(custom_field.name)
100+
custom_fields.append(custom_field.name)
101+
102+
csv_data.append(','.join(headers))
95103

96104
# Iterate through the queryset appending each object
97105
for obj in self.queryset:
98-
data = csv_format(obj.to_csv())
99-
csv_data.append(data)
106+
data = obj.to_csv()
107+
108+
for custom_field in custom_fields:
109+
data += (obj.cf.get(custom_field, ''),)
110+
111+
csv_data.append(csv_format(data))
100112

101113
return '\n'.join(csv_data)
102114

netbox/virtualization/forms.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ class Meta:
9898
}
9999

100100

101-
class ClusterCSVForm(forms.ModelForm):
101+
class ClusterCSVForm(CustomFieldForm):
102102
type = forms.ModelChoiceField(
103103
queryset=ClusterType.objects.all(),
104104
to_field_name='name',
@@ -430,7 +430,7 @@ def __init__(self, *args, **kwargs):
430430
self.fields['primary_ip6'].widget.attrs['readonly'] = True
431431

432432

433-
class VirtualMachineCSVForm(forms.ModelForm):
433+
class VirtualMachineCSVForm(CustomFieldForm):
434434
status = CSVChoiceField(
435435
choices=VirtualMachineStatusChoices,
436436
required=False,

0 commit comments

Comments
 (0)