diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index f088c8e4a..ad96a643d 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -44,3 +44,10 @@ CENSOR_TOKEN_CHANGED = '***CHANGED***' # Placeholder text for empty tables EMPTY_TABLE_TEXT = 'No results found' + +# CSV delimiters +CSV_DELIMITERS = { + 'comma': ',', + 'semicolon': ';', + 'pipe': '|', +} diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index 31e99824e..b537679b3 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -72,6 +72,16 @@ PREFERENCES = { ), description=_('The preferred syntax for displaying generic data within the UI') ), + 'csv_delimiter': UserPreference( + label=_('CSV delimiter'), + choices=( + ('comma', 'Comma (,)'), + ('semicolon', 'Semicolon (;)'), + ('pipe', 'Pipe (|)'), + ), + default='comma', + description=_('The character used to separate fields in CSV data') + ), } diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 9872a158c..11d7bafee 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -15,7 +15,6 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ -from django_tables2.export import TableExport from mptt.models import MPTTModel from core.models import ObjectType @@ -25,6 +24,7 @@ from extras.models import CustomField, ExportTemplate from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation +from utilities.export import TableExport from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields from utilities.forms.bulk_import import BulkImportForm from utilities.htmx import htmx_partial @@ -77,7 +77,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): return '---\n'.join(yaml_data) - def export_table(self, table, columns=None, filename=None): + def export_table(self, table, columns=None, filename=None, delimiter=None): """ Export all table data in CSV format. @@ -86,6 +86,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): columns: A list of specific columns to include. If None, all columns will be exported. filename: The name of the file attachment sent to the client. If None, will be determined automatically from the queryset model name. + delimiter: The character used to separate columns (a comma is used by default) """ exclude_columns = {'pk', 'actions'} if columns: @@ -96,7 +97,8 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): exporter = TableExport( export_format=TableExport.CSV, table=table, - exclude_columns=exclude_columns + exclude_columns=exclude_columns, + delimiter=delimiter, ) return exporter.response( filename=filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' @@ -159,7 +161,8 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): if request.GET['export'] == 'table': table = self.get_table(self.queryset, request, has_table_actions) columns = [name for name, _ in table.selected_columns] - return self.export_table(table, columns) + delimiter = request.user.config.get('csv_delimiter') + return self.export_table(table, columns, delimiter=delimiter) # Render an ExportTemplate elif request.GET['export']: @@ -176,7 +179,8 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): # Fall back to default table/YAML export else: table = self.get_table(self.queryset, request, has_table_actions) - return self.export_table(table) + delimiter = request.user.config.get('csv_delimiter') + return self.export_table(table, delimiter=delimiter) # Render the objects table table = self.get_table(self.queryset, request, has_table_actions) diff --git a/netbox/users/forms/model_forms.py b/netbox/users/forms/model_forms.py index d8773feb4..759696658 100644 --- a/netbox/users/forms/model_forms.py +++ b/netbox/users/forms/model_forms.py @@ -62,7 +62,7 @@ class UserConfigForm(forms.ModelForm, metaclass=UserConfigFormMetaclass): 'ui.tables.striping', name=_('User Interface') ), - FieldSet('data_format', name=_('Miscellaneous')), + FieldSet('data_format', 'csv_delimiter', name=_('Miscellaneous')), ) # List of clearable preferences pk = forms.MultipleChoiceField( diff --git a/netbox/utilities/export.py b/netbox/utilities/export.py new file mode 100644 index 000000000..56ba9506a --- /dev/null +++ b/netbox/utilities/export.py @@ -0,0 +1,26 @@ +from django.utils.translation import gettext_lazy as _ +from django_tables2.export import TableExport as TableExport_ + +from netbox.constants import CSV_DELIMITERS + +__all__ = ( + 'TableExport', +) + + +class TableExport(TableExport_): + """ + A subclass of django-tables2's TableExport class which allows us to specify a delimiting + characters for CSV exports. + """ + def __init__(self, *args, delimiter=None, **kwargs): + if delimiter and delimiter not in CSV_DELIMITERS.keys(): + raise ValueError(_("Invalid delimiter name: {name}").format(name=delimiter)) + self.delimiter = delimiter or 'comma' + super().__init__(*args, **kwargs) + + def export(self): + if self.format == self.CSV and self.delimiter is not None: + delimiter = CSV_DELIMITERS[self.delimiter] + return self.dataset.export(self.format, delimiter=delimiter) + return super().export()