diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 6df63fb6997..997ab751e98 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -2,6 +2,7 @@ ## 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 * [#3509](https://github.com/netbox-community/netbox/issues/3509) - Add IP address variables for custom scripts diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index d5d78e7bd75..d1440381593 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -46,7 +46,7 @@ class Meta: } -class ProviderCSVForm(forms.ModelForm): +class ProviderCSVForm(CustomFieldForm): slug = SlugField() class Meta: @@ -187,7 +187,7 @@ class Meta: } -class CircuitCSVForm(forms.ModelForm): +class CircuitCSVForm(CustomFieldForm): provider = forms.ModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 84d83a94cf8..da4134eedc4 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -264,7 +264,7 @@ class Meta: } -class SiteCSVForm(forms.ModelForm): +class SiteCSVForm(CustomFieldForm): status = CSVChoiceField( choices=SiteStatusChoices, required=False, @@ -505,7 +505,7 @@ class Meta: } -class RackCSVForm(forms.ModelForm): +class RackCSVForm(CustomFieldForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -1714,7 +1714,7 @@ def __init__(self, *args, **kwargs): self.initial['rack'] = self.instance.parent_bay.device.rack_id -class BaseDeviceCSVForm(forms.ModelForm): +class BaseDeviceCSVForm(CustomFieldForm): device_role = forms.ModelChoiceField( queryset=DeviceRole.objects.all(), to_field_name='name', @@ -4264,7 +4264,7 @@ def __init__(self, *args, **kwargs): self.initial['site'] = self.instance.power_panel.site -class PowerFeedCSVForm(forms.ModelForm): +class PowerFeedCSVForm(CustomFieldForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), to_field_name='name', diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index edde6c6c517..314455b839e 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -10,8 +10,8 @@ from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, - CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField, - SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, + CustomFieldChoiceField, CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, + LaxURLField, JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, ) from .choices import * 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 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: choices = [(None, '---------')] + choices # 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 except ObjectDoesNotExist: pass - field = forms.TypedChoiceField( + field = CustomFieldChoiceField( choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2() ) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 1929588400b..005f049b5be 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -1,14 +1,14 @@ from datetime import date from django.contrib.contenttypes.models import ContentType -from django.test import TestCase +from django.test import Client, TestCase from django.urls import reverse from rest_framework import status from dcim.models import Site from extras.choices import * 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 @@ -364,3 +364,62 @@ def test_list_cfc(self): 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_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) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 265ddcb7bb9..3bafad51bd6 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -48,7 +48,7 @@ class Meta: } -class VRFCSVForm(forms.ModelForm): +class VRFCSVForm(CustomFieldForm): tenant = forms.ModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -165,7 +165,7 @@ class Meta: } -class AggregateCSVForm(forms.ModelForm): +class AggregateCSVForm(CustomFieldForm): rir = forms.ModelChoiceField( queryset=RIR.objects.all(), to_field_name='name', @@ -340,7 +340,7 @@ def __init__(self, *args, **kwargs): self.fields['vrf'].empty_label = 'Global' -class PrefixCSVForm(forms.ModelForm): +class PrefixCSVForm(CustomFieldForm): vrf = FlexibleModelChoiceField( queryset=VRF.objects.all(), to_field_name='rd', @@ -759,7 +759,7 @@ def __init__(self, *args, **kwargs): self.fields['vrf'].empty_label = 'Global' -class IPAddressCSVForm(forms.ModelForm): +class IPAddressCSVForm(CustomFieldForm): vrf = FlexibleModelChoiceField( queryset=VRF.objects.all(), to_field_name='rd', @@ -1123,7 +1123,7 @@ class Meta: } -class VLANCSVForm(forms.ModelForm): +class VLANCSVForm(CustomFieldForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), required=False, diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index c937e6c9227..bddb1c10946 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -115,7 +115,7 @@ def clean(self): }) -class SecretCSVForm(forms.ModelForm): +class SecretCSVForm(CustomFieldForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), to_field_name='name', diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index f8aaa45e58d..22deae4343c 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -57,7 +57,7 @@ class Meta: } -class TenantCSVForm(forms.ModelForm): +class TenantCSVForm(CustomFieldForm): slug = SlugField() group = forms.ModelChoiceField( queryset=TenantGroup.objects.all(), diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index a14ec93053f..1dd2c06a718 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -442,6 +442,23 @@ def clean(self, 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): """ A field which allows for numeric range expansion diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 5cb81c6e632..d900a854578 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -88,15 +88,27 @@ def queryset_to_csv(self): Export the queryset of objects as comma-separated value (CSV), using the model's to_csv() method. """ csv_data = [] + custom_fields = [] # Start with the column headers - headers = ','.join(self.queryset.model.csv_headers) - csv_data.append(headers) + headers = self.queryset.model.csv_headers.copy() + + # 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 for obj in self.queryset: - data = csv_format(obj.to_csv()) - csv_data.append(data) + data = obj.to_csv() + + for custom_field in custom_fields: + data += (obj.cf.get(custom_field, ''),) + + csv_data.append(csv_format(data)) return '\n'.join(csv_data) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 910e9a39f34..27c22fa71ef 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -97,7 +97,7 @@ class Meta: } -class ClusterCSVForm(forms.ModelForm): +class ClusterCSVForm(CustomFieldForm): type = forms.ModelChoiceField( queryset=ClusterType.objects.all(), to_field_name='name', @@ -428,7 +428,7 @@ def __init__(self, *args, **kwargs): self.fields['primary_ip6'].widget.attrs['readonly'] = True -class VirtualMachineCSVForm(forms.ModelForm): +class VirtualMachineCSVForm(CustomFieldForm): status = CSVChoiceField( choices=VirtualMachineStatusChoices, required=False,