diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index aff4a65b5..a6981451f 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -17,7 +17,7 @@ from dcim.constants import * from dcim.models import * from ipam.models import ASN, RIR, VLAN, VRF from tenancy.models import Tenant -from utilities.choices import ImportFormatChoices +from utilities.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data from wireless.models import WirelessLAN @@ -2014,6 +2014,7 @@ class ModuleTestCase( 'data': { 'data': '\n'.join(csv_data), 'format': ImportFormatChoices.CSV, + 'csv_delimiter': CSVDelimiterChoices.AUTO, } } @@ -2030,6 +2031,7 @@ class ModuleTestCase( 'data': { 'data': '\n'.join(csv_data), 'format': ImportFormatChoices.CSV, + 'csv_delimiter': CSVDelimiterChoices.AUTO, } } @@ -2106,6 +2108,7 @@ class ModuleTestCase( 'data': { 'data': '\n'.join(csv_data), 'format': ImportFormatChoices.CSV, + 'csv_delimiter': CSVDelimiterChoices.AUTO, } } diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index a8153e1bb..7ac6b2035 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -12,6 +12,7 @@ from dcim.models import Manufacturer, Rack, Site from extras.choices import * from extras.models import CustomField, CustomFieldChoiceSet from ipam.models import VLAN +from utilities.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine @@ -1176,7 +1177,11 @@ class CustomFieldImportTest(TestCase): ) csv_data = '\n'.join(','.join(row) for row in data) - response = self.client.post(reverse('dcim:site_import'), {'data': csv_data, 'format': 'csv'}) + response = self.client.post(reverse('dcim:site_import'), { + 'data': csv_data, + 'format': ImportFormatChoices.CSV, + 'csv_delimiter': CSVDelimiterChoices.AUTO, + }) self.assertEqual(response.status_code, 302) self.assertEqual(Site.objects.count(), 3) diff --git a/netbox/netbox/tests/test_import.py b/netbox/netbox/tests/test_import.py index 73f775bd7..6594409f2 100644 --- a/netbox/netbox/tests/test_import.py +++ b/netbox/netbox/tests/test_import.py @@ -3,7 +3,7 @@ from django.test import override_settings from dcim.models import * from users.models import ObjectPermission -from utilities.choices import ImportFormatChoices +from utilities.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.testing import ModelViewTestCase, create_tags @@ -30,6 +30,7 @@ class CSVImportTestCase(ModelViewTestCase): data = { 'format': ImportFormatChoices.CSV, 'data': self._get_csv_data(csv_data), + 'csv_delimiter': CSVDelimiterChoices.AUTO, } # Assign model-level permission diff --git a/netbox/templates/generic/bulk_import.html b/netbox/templates/generic/bulk_import.html index 69c98f7ac..b9cb0d4cb 100644 --- a/netbox/templates/generic/bulk_import.html +++ b/netbox/templates/generic/bulk_import.html @@ -45,6 +45,7 @@ Context: {% render_field form.data %} {% render_field form.format %} + {% render_field form.csv_delimiter %}
diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index b6f97e309..77bfc03ca 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -1,6 +1,8 @@ from django.conf import settings from django.utils.translation import gettext_lazy as _ +from .constants import CSV_DELIMITERS + class ChoiceSetMeta(type): """ @@ -230,3 +232,17 @@ class ImportFormatChoices(ChoiceSet): (JSON, 'JSON'), (YAML, 'YAML'), ] + + +class CSVDelimiterChoices(ChoiceSet): + AUTO = 'auto' + COMMA = CSV_DELIMITERS['comma'] + SEMICOLON = CSV_DELIMITERS['semicolon'] + TAB = CSV_DELIMITERS['tab'] + + CHOICES = [ + (AUTO, _('Auto-detect')), + (COMMA, _('Comma')), + (SEMICOLON, _('Semicolon')), + (TAB, _('Tab')), + ] diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 5c551a810..345894065 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -58,3 +58,14 @@ HTTP_REQUEST_META_SAFE_COPY = [ 'SERVER_NAME', 'SERVER_PORT', ] + + +# +# CSV-style format delimiters +# + +CSV_DELIMITERS = { + 'comma': ',', + 'semicolon': ';', + 'tab': '\t', +} diff --git a/netbox/utilities/forms/bulk_import.py b/netbox/utilities/forms/bulk_import.py index 6bdfd5662..63cec2ba2 100644 --- a/netbox/utilities/forms/bulk_import.py +++ b/netbox/utilities/forms/bulk_import.py @@ -7,10 +7,10 @@ from django import forms from django.utils.translation import gettext as _ from core.forms.mixins import SyncedDataMixin -from utilities.choices import ImportFormatChoices +from utilities.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices +from utilities.constants import CSV_DELIMITERS from utilities.forms.utils import parse_csv from .mixins import BootstrapMixin -from ..choices import ImportMethodChoices class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form): @@ -24,13 +24,20 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form): help_text=_("Enter object data in CSV, JSON or YAML format.") ) upload_file = forms.FileField( - label="Data file", + label=_("Data file"), required=False ) format = forms.ChoiceField( choices=ImportFormatChoices, initial=ImportFormatChoices.AUTO ) + csv_delimiter = forms.ChoiceField( + choices=CSVDelimiterChoices, + initial=CSVDelimiterChoices.AUTO, + label=_("CSV delimiter"), + help_text=_("The character which delimits CSV fields. Applies only to CSV format."), + required=False + ) data_field = 'data' @@ -54,13 +61,18 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form): # Determine the data format if self.cleaned_data['format'] == ImportFormatChoices.AUTO: - format = self._detect_format(data) + if self.cleaned_data['csv_delimiter'] != CSVDelimiterChoices.AUTO: + # Specifying the CSV delimiter implies CSV format + format = ImportFormatChoices.CSV + else: + format = self._detect_format(data) else: format = self.cleaned_data['format'] # Process data according to the selected format if format == ImportFormatChoices.CSV: - self.cleaned_data['data'] = self._clean_csv(data) + delimiter = self.cleaned_data.get('csv_delimiter', CSVDelimiterChoices.AUTO) + self.cleaned_data['data'] = self._clean_csv(data, delimiter=delimiter) elif format == ImportFormatChoices.JSON: self.cleaned_data['data'] = self._clean_json(data) elif format == ImportFormatChoices.YAML: @@ -78,7 +90,10 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form): return ImportFormatChoices.JSON if data.startswith('---') or data.startswith('- '): return ImportFormatChoices.YAML - if ',' in data.split('\n', 1)[0]: + # Look for any of the CSV delimiters in the first line (ignoring the default 'auto' choice) + first_line = data.split('\n', 1)[0] + csv_delimiters = CSV_DELIMITERS.values() + if any(x in first_line for x in csv_delimiters): return ImportFormatChoices.CSV except IndexError: pass @@ -86,12 +101,31 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form): 'format': _('Unable to detect data format. Please specify.') }) - def _clean_csv(self, data): + def _clean_csv(self, data, delimiter=CSVDelimiterChoices.AUTO): """ Clean CSV-formatted data. The first row will be treated as column headers. """ + # Determine the CSV dialect + if delimiter == CSVDelimiterChoices.AUTO: + # This uses a rough heuristic to detect the CSV dialect based on the presence of supported delimiting + # characters. If the data is malformed, we'll fall back to the default Excel dialect. + delimiters = ''.join(CSV_DELIMITERS.values()) + try: + dialect = csv.Sniffer().sniff(data.strip(), delimiters=delimiters) + except csv.Error: + dialect = csv.excel + elif delimiter in (CSVDelimiterChoices.COMMA, CSVDelimiterChoices.SEMICOLON): + dialect = csv.excel + dialect.delimiter = delimiter + elif delimiter == CSVDelimiterChoices.TAB: + dialect = csv.excel_tab + else: + raise forms.ValidationError({ + 'csv_delimiter': _('Invalid CSV delimiter'), + }) + stream = StringIO(data.strip()) - reader = csv.reader(stream) + reader = csv.reader(stream, dialect=dialect) headers, records = parse_csv(reader) # Set CSV headers for reference by the model form diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 3c2dc3c45..0a84c5d1b 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -11,7 +11,7 @@ from extras.choices import ObjectChangeActionChoices from extras.models import ObjectChange from netbox.models.features import ChangeLoggingMixin from users.models import ObjectPermission -from utilities.choices import ImportFormatChoices +from utilities.choices import CSVDelimiterChoices, ImportFormatChoices from .base import ModelTestCase from .utils import disable_warnings, post_data @@ -580,7 +580,8 @@ class ViewTestCases: def test_bulk_import_objects_without_permission(self): data = { 'data': self._get_csv_data(), - 'format': 'csv', + 'format': ImportFormatChoices.CSV, + 'csv_delimiter': CSVDelimiterChoices.AUTO, } # Test GET without permission @@ -597,7 +598,8 @@ class ViewTestCases: initial_count = self._get_queryset().count() data = { 'data': self._get_csv_data(), - 'format': 'csv', + 'format': ImportFormatChoices.CSV, + 'csv_delimiter': CSVDelimiterChoices.AUTO, } # Assign model-level permission @@ -626,6 +628,7 @@ class ViewTestCases: data = { 'format': ImportFormatChoices.CSV, 'data': csv_data, + 'csv_delimiter': CSVDelimiterChoices.AUTO, } # Assign model-level permission @@ -658,7 +661,8 @@ class ViewTestCases: initial_count = self._get_queryset().count() data = { 'data': self._get_csv_data(), - 'format': 'csv', + 'format': ImportFormatChoices.CSV, + 'csv_delimiter': CSVDelimiterChoices.AUTO, } # Assign constrained permission diff --git a/netbox/utilities/tests/test_forms.py b/netbox/utilities/tests/test_forms.py index 79ba3f4d8..8fd001ba8 100644 --- a/netbox/utilities/tests/test_forms.py +++ b/netbox/utilities/tests/test_forms.py @@ -331,3 +331,36 @@ class ImportFormTest(TestCase): form._detect_format('') with self.assertRaises(forms.ValidationError): form._detect_format('?') + + def test_csv_delimiters(self): + form = BulkImportForm() + + data = ( + "a,b,c\n" + "1,2,3\n" + "4,5,6\n" + ) + self.assertEqual(form._clean_csv(data, delimiter=','), [ + {'a': '1', 'b': '2', 'c': '3'}, + {'a': '4', 'b': '5', 'c': '6'}, + ]) + + data = ( + "a;b;c\n" + "1;2;3\n" + "4;5;6\n" + ) + self.assertEqual(form._clean_csv(data, delimiter=';'), [ + {'a': '1', 'b': '2', 'c': '3'}, + {'a': '4', 'b': '5', 'c': '6'}, + ]) + + data = ( + "a\tb\tc\n" + "1\t2\t3\n" + "4\t5\t6\n" + ) + self.assertEqual(form._clean_csv(data, delimiter='\t'), [ + {'a': '1', 'b': '2', 'c': '3'}, + {'a': '4', 'b': '5', 'c': '6'}, + ])