1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge pull request #3885 from hSaria/568-csv-import-cf

Fixes #568: CSV import/export of custom fields
This commit is contained in:
Jeremy Stretch
2020-01-29 10:11:40 -05:00
committed by GitHub
11 changed files with 114 additions and 25 deletions

View File

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

View File

@ -46,7 +46,7 @@ class ProviderForm(BootstrapMixin, CustomFieldForm):
} }
class ProviderCSVForm(forms.ModelForm): class ProviderCSVForm(CustomFieldForm):
slug = SlugField() slug = SlugField()
class Meta: class Meta:
@ -188,7 +188,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
} }
class CircuitCSVForm(forms.ModelForm): class CircuitCSVForm(CustomFieldForm):
provider = forms.ModelChoiceField( provider = forms.ModelChoiceField(
queryset=Provider.objects.all(), queryset=Provider.objects.all(),
to_field_name='name', to_field_name='name',

View File

@ -263,7 +263,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
} }
class SiteCSVForm(forms.ModelForm): class SiteCSVForm(CustomFieldForm):
status = CSVChoiceField( status = CSVChoiceField(
choices=SiteStatusChoices, choices=SiteStatusChoices,
required=False, required=False,
@ -504,7 +504,7 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldForm):
} }
class RackCSVForm(forms.ModelForm): class RackCSVForm(CustomFieldForm):
site = forms.ModelChoiceField( site = forms.ModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='name', to_field_name='name',
@ -1724,7 +1724,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
self.initial['rack'] = self.instance.parent_bay.device.rack_id self.initial['rack'] = self.instance.parent_bay.device.rack_id
class BaseDeviceCSVForm(forms.ModelForm): class BaseDeviceCSVForm(CustomFieldForm):
device_role = forms.ModelChoiceField( device_role = forms.ModelChoiceField(
queryset=DeviceRole.objects.all(), queryset=DeviceRole.objects.all(),
to_field_name='name', to_field_name='name',
@ -4286,7 +4286,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldForm):
self.initial['site'] = self.instance.power_panel.site self.initial['site'] = self.instance.power_panel.site
class PowerFeedCSVForm(forms.ModelForm): class PowerFeedCSVForm(CustomFieldForm):
site = forms.ModelChoiceField( site = forms.ModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
to_field_name='name', to_field_name='name',

View File

@ -10,8 +10,8 @@ from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField, CustomFieldChoiceField, CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField,
SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, LaxURLField, JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
) )
from .choices import * from .choices import *
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag 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
# Select # Select
elif cf.type == CustomFieldTypeChoices.TYPE_SELECT: elif cf.type == CustomFieldTypeChoices.TYPE_SELECT:
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()] choices = [(cfc.pk, cfc.value) for cfc in cf.choices.all()]
if not cf.required or bulk_edit or filterable_only: if not cf.required or bulk_edit or filterable_only:
choices = [(None, '---------')] + choices choices = [(None, '---------')] + choices
# Check for a default choice # Check for a default choice
@ -71,7 +71,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
default_choice = cf.choices.get(value=initial).pk default_choice = cf.choices.get(value=initial).pk
except ObjectDoesNotExist: except ObjectDoesNotExist:
pass pass
field = forms.TypedChoiceField( field = CustomFieldChoiceField(
choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2() choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2()
) )

View File

@ -1,14 +1,14 @@
from datetime import date from datetime import date
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
from dcim.models import Site from dcim.models import Site
from extras.choices import * from extras.choices import *
from extras.models import CustomField, CustomFieldValue, CustomFieldChoice from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
from utilities.testing import APITestCase from utilities.testing import APITestCase, create_test_user
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
@ -364,3 +364,62 @@ class CustomFieldChoiceAPITest(APITestCase):
self.assertEqual(self.cf_choice_1.pk, response.data[self.cf_1.name][self.cf_choice_1.value]) self.assertEqual(self.cf_choice_1.pk, response.data[self.cf_1.name][self.cf_choice_1.value])
self.assertEqual(self.cf_choice_2.pk, response.data[self.cf_1.name][self.cf_choice_2.value]) self.assertEqual(self.cf_choice_2.pk, response.data[self.cf_1.name][self.cf_choice_2.value])
self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value]) self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value])
class CustomFieldCSV(TestCase):
def setUp(self):
super().setUp()
user = create_test_user(
permissions=[
'dcim.view_site',
'dcim.add_site',
]
)
self.client = Client()
self.client.force_login(user)
obj_type = ContentType.objects.get_for_model(Site)
self.cf_text = CustomField.objects.create(name="text", type=CustomFieldTypeChoices.TYPE_TEXT)
self.cf_text.obj_type.set([obj_type])
self.cf_text.save()
self.cf_choice = CustomField.objects.create(name="choice", type=CustomFieldTypeChoices.TYPE_SELECT)
self.cf_choice.obj_type.set([obj_type])
self.cf_choice.save()
self.cf_choice_1 = CustomFieldChoice.objects.create(field=self.cf_choice, value="cf_field_1")
self.cf_choice_2 = CustomFieldChoice.objects.create(field=self.cf_choice, value="cf_field_2")
self.cf_choice_3 = CustomFieldChoice.objects.create(field=self.cf_choice, value="cf_field_3")
def test_import(self):
"""
Import a site with custom fields
"""
csv_data = (
"name,slug,cf_text,cf_choice",
"Site 1,site-1,something,cf_field_1",
)
response = self.client.post(reverse('dcim:site_import'), {'csv': '\n'.join(csv_data)})
self.assertEqual(response.status_code, 200)
site1_custom_fields = Site.objects.get(name='Site 1').get_custom_fields()
self.assertEqual(len(site1_custom_fields), 2)
self.assertEqual(site1_custom_fields[self.cf_text], 'something')
self.assertEqual(site1_custom_fields[self.cf_choice], self.cf_choice_1)
def test_import_invalid_choice(self):
"""
Import a site with an invalid choice
"""
csv_data = (
"name,slug,cf_choice",
"Site 2,site-2,cf_field_4",
)
response = self.client.post(reverse('dcim:site_import'), {'csv': '\n'.join(csv_data)})
self.assertEqual(response.status_code, 200)
self.assertFalse(len(Site.objects.filter(name="Site 2")), 0)

View File

@ -49,7 +49,7 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
} }
class VRFCSVForm(forms.ModelForm): class VRFCSVForm(CustomFieldForm):
tenant = forms.ModelChoiceField( tenant = forms.ModelChoiceField(
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False, required=False,
@ -166,7 +166,7 @@ class AggregateForm(BootstrapMixin, CustomFieldForm):
} }
class AggregateCSVForm(forms.ModelForm): class AggregateCSVForm(CustomFieldForm):
rir = forms.ModelChoiceField( rir = forms.ModelChoiceField(
queryset=RIR.objects.all(), queryset=RIR.objects.all(),
to_field_name='name', to_field_name='name',
@ -341,7 +341,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
self.fields['vrf'].empty_label = 'Global' self.fields['vrf'].empty_label = 'Global'
class PrefixCSVForm(forms.ModelForm): class PrefixCSVForm(CustomFieldForm):
vrf = FlexibleModelChoiceField( vrf = FlexibleModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
to_field_name='rd', to_field_name='rd',
@ -771,7 +771,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
self.fields['vrf'].empty_label = 'Global' self.fields['vrf'].empty_label = 'Global'
class IPAddressCSVForm(forms.ModelForm): class IPAddressCSVForm(CustomFieldForm):
vrf = FlexibleModelChoiceField( vrf = FlexibleModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
to_field_name='rd', to_field_name='rd',
@ -1135,7 +1135,7 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
} }
class VLANCSVForm(forms.ModelForm): class VLANCSVForm(CustomFieldForm):
site = forms.ModelChoiceField( site = forms.ModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False, required=False,

View File

@ -116,7 +116,7 @@ class SecretForm(BootstrapMixin, CustomFieldForm):
}) })
class SecretCSVForm(forms.ModelForm): class SecretCSVForm(CustomFieldForm):
device = FlexibleModelChoiceField( device = FlexibleModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
to_field_name='name', to_field_name='name',

View File

@ -57,7 +57,7 @@ class TenantForm(BootstrapMixin, CustomFieldForm):
} }
class TenantCSVForm(forms.ModelForm): class TenantCSVForm(CustomFieldForm):
slug = SlugField() slug = SlugField()
group = forms.ModelChoiceField( group = forms.ModelChoiceField(
queryset=TenantGroup.objects.all(), queryset=TenantGroup.objects.all(),

View File

@ -442,6 +442,23 @@ class CSVChoiceField(forms.ChoiceField):
return self.choice_values[value] return self.choice_values[value]
class CustomFieldChoiceField(forms.TypedChoiceField):
"""
Accept human-friendly label as input, and return the database value. If the label is not matched, the normal,
value-based input is assumed.
"""
def __init__(self, choices, *args, **kwargs):
super().__init__(choices=choices, *args, **kwargs)
self.choice_values = {label: value for value, label in unpack_grouped_choices(choices)}
def clean(self, value):
# Check if the value is actually a label
if value in self.choice_values:
return self.choice_values[value]
return super().clean(value)
class ExpandableNameField(forms.CharField): class ExpandableNameField(forms.CharField):
""" """
A field which allows for numeric range expansion A field which allows for numeric range expansion

View File

@ -88,15 +88,27 @@ class ObjectListView(View):
Export the queryset of objects as comma-separated value (CSV), using the model's to_csv() method. Export the queryset of objects as comma-separated value (CSV), using the model's to_csv() method.
""" """
csv_data = [] csv_data = []
custom_fields = []
# Start with the column headers # Start with the column headers
headers = ','.join(self.queryset.model.csv_headers) headers = self.queryset.model.csv_headers.copy()
csv_data.append(headers)
# Add custom field headers, if any
if hasattr(self.queryset.model, 'get_custom_fields'):
for custom_field in self.queryset.model().get_custom_fields():
headers.append(custom_field.name)
custom_fields.append(custom_field.name)
csv_data.append(','.join(headers))
# Iterate through the queryset appending each object # Iterate through the queryset appending each object
for obj in self.queryset: for obj in self.queryset:
data = csv_format(obj.to_csv()) data = obj.to_csv()
csv_data.append(data)
for custom_field in custom_fields:
data += (obj.cf.get(custom_field, ''),)
csv_data.append(csv_format(data))
return '\n'.join(csv_data) return '\n'.join(csv_data)

View File

@ -98,7 +98,7 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldForm):
} }
class ClusterCSVForm(forms.ModelForm): class ClusterCSVForm(CustomFieldForm):
type = forms.ModelChoiceField( type = forms.ModelChoiceField(
queryset=ClusterType.objects.all(), queryset=ClusterType.objects.all(),
to_field_name='name', to_field_name='name',
@ -430,7 +430,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
self.fields['primary_ip6'].widget.attrs['readonly'] = True self.fields['primary_ip6'].widget.attrs['readonly'] = True
class VirtualMachineCSVForm(forms.ModelForm): class VirtualMachineCSVForm(CustomFieldForm):
status = CSVChoiceField( status = CSVChoiceField(
choices=VirtualMachineStatusChoices, choices=VirtualMachineStatusChoices,
required=False, required=False,