Skip to content

Commit b9765b8

Browse files
Merge pull request #4050 from netbox-community/568-customfield-csv-import
Closes #568: Extend CSV import to support custom fields
2 parents d0d2af4 + eafeaab commit b9765b8

File tree

11 files changed

+304
-146
lines changed

11 files changed

+304
-146
lines changed

docs/release-notes/version-2.7.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# v2.7.4 (FUTURE)
22

3+
## Enhancements
4+
5+
* [#568](https://github.com/netbox-community/netbox/issues/568) - Allow custom fields to be imported and exported using CSV
6+
37
## Bug Fixes
48

59
* [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts

netbox/circuits/forms.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
from taggit.forms import TagField
33

44
from dcim.models import Region, Site
5-
from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
5+
from extras.forms import (
6+
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
7+
)
68
from tenancy.forms import TenancyFilterForm, TenancyForm
79
from tenancy.models import Tenant
810
from utilities.forms import (
@@ -17,7 +19,7 @@
1719
# Providers
1820
#
1921

20-
class ProviderForm(BootstrapMixin, CustomFieldForm):
22+
class ProviderForm(BootstrapMixin, CustomFieldModelForm):
2123
slug = SlugField()
2224
comments = CommentField()
2325
tags = TagField(
@@ -46,7 +48,7 @@ class Meta:
4648
}
4749

4850

49-
class ProviderCSVForm(forms.ModelForm):
51+
class ProviderCSVForm(CustomFieldModelCSVForm):
5052
slug = SlugField()
5153

5254
class Meta:
@@ -160,7 +162,7 @@ class Meta:
160162
# Circuits
161163
#
162164

163-
class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
165+
class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
164166
comments = CommentField()
165167
tags = TagField(
166168
required=False
@@ -188,7 +190,7 @@ class Meta:
188190
}
189191

190192

191-
class CircuitCSVForm(forms.ModelForm):
193+
class CircuitCSVForm(CustomFieldModelCSVForm):
192194
provider = forms.ModelChoiceField(
193195
queryset=Provider.objects.all(),
194196
to_field_name='name',

netbox/dcim/forms.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313

1414
from circuits.models import Circuit, Provider
1515
from extras.forms import (
16-
AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm, LocalConfigContextFilterForm
16+
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm,
17+
LocalConfigContextFilterForm,
1718
)
1819
from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
1920
from ipam.models import IPAddress, VLAN
@@ -215,7 +216,7 @@ class RegionFilterForm(BootstrapMixin, forms.Form):
215216
# Sites
216217
#
217218

218-
class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
219+
class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
219220
region = TreeNodeChoiceField(
220221
queryset=Region.objects.all(),
221222
required=False,
@@ -263,7 +264,7 @@ class Meta:
263264
}
264265

265266

266-
class SiteCSVForm(forms.ModelForm):
267+
class SiteCSVForm(CustomFieldModelCSVForm):
267268
status = CSVChoiceField(
268269
choices=SiteStatusChoices,
269270
required=False,
@@ -459,7 +460,7 @@ class Meta:
459460
# Racks
460461
#
461462

462-
class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
463+
class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
463464
group = ChainedModelChoiceField(
464465
queryset=RackGroup.objects.all(),
465466
chains=(
@@ -504,7 +505,7 @@ class Meta:
504505
}
505506

506507

507-
class RackCSVForm(forms.ModelForm):
508+
class RackCSVForm(CustomFieldModelCSVForm):
508509
site = forms.ModelChoiceField(
509510
queryset=Site.objects.all(),
510511
to_field_name='name',
@@ -897,7 +898,7 @@ class Meta:
897898
# Device types
898899
#
899900

900-
class DeviceTypeForm(BootstrapMixin, CustomFieldForm):
901+
class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
901902
slug = SlugField(
902903
slug_source='model'
903904
)
@@ -1516,7 +1517,7 @@ class Meta:
15161517
# Devices
15171518
#
15181519

1519-
class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
1520+
class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
15201521
site = forms.ModelChoiceField(
15211522
queryset=Site.objects.all(),
15221523
widget=APISelect(
@@ -1724,7 +1725,7 @@ def __init__(self, *args, **kwargs):
17241725
self.initial['rack'] = self.instance.parent_bay.device.rack_id
17251726

17261727

1727-
class BaseDeviceCSVForm(forms.ModelForm):
1728+
class BaseDeviceCSVForm(CustomFieldModelCSVForm):
17281729
device_role = forms.ModelChoiceField(
17291730
queryset=DeviceRole.objects.all(),
17301731
to_field_name='name',
@@ -4241,7 +4242,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
42414242
# Power feeds
42424243
#
42434244

4244-
class PowerFeedForm(BootstrapMixin, CustomFieldForm):
4245+
class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
42454246
site = ChainedModelChoiceField(
42464247
queryset=Site.objects.all(),
42474248
required=False,
@@ -4286,7 +4287,7 @@ def __init__(self, *args, **kwargs):
42864287
self.initial['site'] = self.instance.power_panel.site
42874288

42884289

4289-
class PowerFeedCSVForm(forms.ModelForm):
4290+
class PowerFeedCSVForm(CustomFieldModelCSVForm):
42904291
site = forms.ModelChoiceField(
42914292
queryset=Site.objects.all(),
42924293
to_field_name='name',

netbox/extras/forms.py

Lines changed: 50 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
1-
from collections import OrderedDict
2-
31
from django import forms
42
from django.contrib.auth.models import User
53
from django.contrib.contenttypes.models import ContentType
6-
from django.core.exceptions import ObjectDoesNotExist
74
from taggit.forms import TagField
85

96
from dcim.models import DeviceRole, Platform, Region, Site
107
from tenancy.models import Tenant, TenantGroup
118
from utilities.forms import (
129
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
13-
CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField,
14-
SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
10+
CommentField, ContentTypeSelect, DateTimePicker, FilterChoiceField, JSONField, SlugField, StaticSelect2,
11+
BOOLEAN_WITH_BLANK_CHOICES,
1512
)
1613
from .choices import *
1714
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
@@ -21,102 +18,41 @@
2118
# Custom fields
2219
#
2320

24-
def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False):
25-
"""
26-
Retrieve all CustomFields applicable to the given ContentType
27-
"""
28-
field_dict = OrderedDict()
29-
custom_fields = CustomField.objects.filter(obj_type=content_type)
30-
if filterable_only:
31-
custom_fields = custom_fields.exclude(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED)
32-
33-
for cf in custom_fields:
34-
field_name = 'cf_{}'.format(str(cf.name))
35-
initial = cf.default if not bulk_edit else None
36-
37-
# Integer
38-
if cf.type == CustomFieldTypeChoices.TYPE_INTEGER:
39-
field = forms.IntegerField(required=cf.required, initial=initial)
40-
41-
# Boolean
42-
elif cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
43-
choices = (
44-
(None, '---------'),
45-
(1, 'True'),
46-
(0, 'False'),
47-
)
48-
if initial is not None and initial.lower() in ['true', 'yes', '1']:
49-
initial = 1
50-
elif initial is not None and initial.lower() in ['false', 'no', '0']:
51-
initial = 0
52-
else:
53-
initial = None
54-
field = forms.NullBooleanField(
55-
required=cf.required, initial=initial, widget=StaticSelect2(choices=choices)
56-
)
57-
58-
# Date
59-
elif cf.type == CustomFieldTypeChoices.TYPE_DATE:
60-
field = forms.DateField(required=cf.required, initial=initial, widget=DatePicker())
61-
62-
# Select
63-
elif cf.type == CustomFieldTypeChoices.TYPE_SELECT:
64-
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
65-
if not cf.required or bulk_edit or filterable_only:
66-
choices = [(None, '---------')] + choices
67-
# Check for a default choice
68-
default_choice = None
69-
if initial:
70-
try:
71-
default_choice = cf.choices.get(value=initial).pk
72-
except ObjectDoesNotExist:
73-
pass
74-
field = forms.TypedChoiceField(
75-
choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2()
76-
)
77-
78-
# URL
79-
elif cf.type == CustomFieldTypeChoices.TYPE_URL:
80-
field = LaxURLField(required=cf.required, initial=initial)
81-
82-
# Text
83-
else:
84-
field = forms.CharField(max_length=255, required=cf.required, initial=initial)
85-
86-
field.model = cf
87-
field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()
88-
if cf.description:
89-
field.help_text = cf.description
90-
91-
field_dict[field_name] = field
92-
93-
return field_dict
94-
95-
96-
class CustomFieldForm(forms.ModelForm):
21+
class CustomFieldModelForm(forms.ModelForm):
9722

9823
def __init__(self, *args, **kwargs):
9924

100-
self.custom_fields = []
10125
self.obj_type = ContentType.objects.get_for_model(self._meta.model)
26+
self.custom_fields = []
27+
self.custom_field_values = {}
10228

10329
super().__init__(*args, **kwargs)
10430

105-
# Add all applicable CustomFields to the form
106-
custom_fields = []
107-
for name, field in get_custom_fields_for_model(self.obj_type).items():
108-
self.fields[name] = field
109-
custom_fields.append(name)
110-
self.custom_fields = custom_fields
31+
self._append_customfield_fields()
11132

112-
# If editing an existing object, initialize values for all custom fields
33+
def _append_customfield_fields(self):
34+
"""
35+
Append form fields for all CustomFields assigned to this model.
36+
"""
37+
# Retrieve initial CustomField values for the instance
11338
if self.instance.pk:
114-
existing_values = CustomFieldValue.objects.filter(
39+
for cfv in CustomFieldValue.objects.filter(
11540
obj_type=self.obj_type,
11641
obj_id=self.instance.pk
117-
).prefetch_related('field')
118-
for cfv in existing_values:
119-
self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value
42+
).prefetch_related('field'):
43+
self.custom_field_values[cfv.field.name] = cfv.serialized_value
44+
45+
# Append form fields; assign initial values if modifying and existing object
46+
for cf in CustomField.objects.filter(obj_type=self.obj_type):
47+
field_name = 'cf_{}'.format(cf.name)
48+
if self.instance.pk:
49+
self.fields[field_name] = cf.to_form_field(set_initial=False)
50+
self.fields[field_name].initial = self.custom_field_values.get(cf.name)
51+
else:
52+
self.fields[field_name] = cf.to_form_field()
53+
54+
# Annotate the field in the list of CustomField form fields
55+
self.custom_fields.append(field_name)
12056

12157
def _save_custom_fields(self):
12258

@@ -151,6 +87,19 @@ def save(self, commit=True):
15187
return obj
15288

15389

90+
class CustomFieldModelCSVForm(CustomFieldModelForm):
91+
92+
def _append_customfield_fields(self):
93+
94+
# Append form fields
95+
for cf in CustomField.objects.filter(obj_type=self.obj_type):
96+
field_name = 'cf_{}'.format(cf.name)
97+
self.fields[field_name] = cf.to_form_field(for_csv_import=True)
98+
99+
# Annotate the field in the list of CustomField form fields
100+
self.custom_fields.append(field_name)
101+
102+
154103
class CustomFieldBulkEditForm(BulkEditForm):
155104

156105
def __init__(self, *args, **kwargs):
@@ -160,15 +109,14 @@ def __init__(self, *args, **kwargs):
160109
self.obj_type = ContentType.objects.get_for_model(self.model)
161110

162111
# Add all applicable CustomFields to the form
163-
custom_fields = get_custom_fields_for_model(self.obj_type, bulk_edit=True).items()
164-
for name, field in custom_fields:
112+
custom_fields = CustomField.objects.filter(obj_type=self.obj_type)
113+
for cf in custom_fields:
165114
# Annotate non-required custom fields as nullable
166-
if not field.required:
167-
self.nullable_fields.append(name)
168-
field.required = False
169-
self.fields[name] = field
115+
if not cf.required:
116+
self.nullable_fields.append(cf.name)
117+
self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
170118
# Annotate this as a custom field
171-
self.custom_fields.append(name)
119+
self.custom_fields.append(cf.name)
172120

173121

174122
class CustomFieldFilterForm(forms.Form):
@@ -180,10 +128,11 @@ def __init__(self, *args, **kwargs):
180128
super().__init__(*args, **kwargs)
181129

182130
# Add all applicable CustomFields to the form
183-
custom_fields = get_custom_fields_for_model(self.obj_type, filterable_only=True).items()
184-
for name, field in custom_fields:
185-
field.required = False
186-
self.fields[name] = field
131+
custom_fields = CustomField.objects.filter(obj_type=self.obj_type).exclude(
132+
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
133+
)
134+
for cf in custom_fields:
135+
self.fields[cf.name] = cf.to_form_field(set_initial=True, enforce_required=False)
187136

188137

189138
#

0 commit comments

Comments
 (0)