Coverage for utilities/forms/bulk_import.py: 18%
97 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import csv
2import json
3from io import StringIO
5import yaml
6from django import forms
7from django.utils.translation import gettext as _
9from core.forms.mixins import SyncedDataMixin
10from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, ImportMethodChoices
11from netbox.forms.mixins import ChangelogMessageMixin
12from utilities.constants import CSV_DELIMITERS
13from utilities.forms.mixins import BackgroundJobMixin
14from utilities.forms.utils import parse_csv
17class BulkImportForm(ChangelogMessageMixin, BackgroundJobMixin, SyncedDataMixin, forms.Form):
18 import_method = forms.ChoiceField(
19 choices=ImportMethodChoices,
20 required=False
21 )
22 data = forms.CharField(
23 required=False,
24 widget=forms.Textarea(attrs={'class': 'font-monospace'}),
25 help_text=_("Enter object data in CSV, JSON or YAML format.")
26 )
27 upload_file = forms.FileField(
28 label=_("Data file"),
29 required=False
30 )
31 format = forms.ChoiceField(
32 choices=ImportFormatChoices,
33 initial=ImportFormatChoices.AUTO
34 )
35 csv_delimiter = forms.ChoiceField(
36 choices=CSVDelimiterChoices,
37 initial=CSVDelimiterChoices.AUTO,
38 label=_("CSV delimiter"),
39 help_text=_("The character which delimits CSV fields. Applies only to CSV format."),
40 required=False
41 )
43 data_field = 'data'
45 def clean(self):
46 super().clean()
48 # Determine import method
49 import_method = self.cleaned_data.get('import_method') or ImportMethodChoices.DIRECT
51 # Determine whether we're reading from form data or an uploaded file
52 if self.cleaned_data['data'] and import_method != ImportMethodChoices.DIRECT:
53 raise forms.ValidationError(_("Form data must be empty when uploading/selecting a file."))
54 if import_method == ImportMethodChoices.UPLOAD:
55 self.data_field = 'upload_file'
56 file = self.files.get('upload_file')
57 data = file.read().decode('utf-8-sig')
58 elif import_method == ImportMethodChoices.DATA_FILE:
59 self.data_field = 'data_file'
60 data = self.cleaned_data['data_file'].data_as_string
61 else:
62 data = self.cleaned_data['data']
64 # Determine the data format
65 if self.cleaned_data['format'] == ImportFormatChoices.AUTO:
66 if self.cleaned_data['csv_delimiter'] != CSVDelimiterChoices.AUTO:
67 # Specifying the CSV delimiter implies CSV format
68 format = ImportFormatChoices.CSV
69 else:
70 format = self._detect_format(data)
71 else:
72 format = self.cleaned_data['format']
74 # Process data according to the selected format
75 if format == ImportFormatChoices.CSV:
76 delimiter = self.cleaned_data.get('csv_delimiter', CSVDelimiterChoices.AUTO)
77 self.cleaned_data['data'] = self._clean_csv(data, delimiter=delimiter)
78 elif format == ImportFormatChoices.JSON:
79 self.cleaned_data['data'] = self._clean_json(data)
80 elif format == ImportFormatChoices.YAML:
81 self.cleaned_data['data'] = self._clean_yaml(data)
82 else:
83 raise forms.ValidationError(_("Unknown data format: {format}").format(format=format))
85 def _detect_format(self, data):
86 """
87 Attempt to automatically detect the format (CSV, JSON, or YAML) of the given data, or raise
88 a ValidationError.
89 """
90 try:
91 if data[0] in ('{', '['):
92 return ImportFormatChoices.JSON
93 if data.startswith('---') or data.startswith('- '):
94 return ImportFormatChoices.YAML
95 # Look for any of the CSV delimiters in the first line (ignoring the default 'auto' choice)
96 first_line = data.split('\n', 1)[0]
97 csv_delimiters = CSV_DELIMITERS.values()
98 if any(x in first_line for x in csv_delimiters):
99 return ImportFormatChoices.CSV
100 except IndexError:
101 pass
102 raise forms.ValidationError({
103 'format': _('Unable to detect data format. Please specify.')
104 })
106 def _clean_csv(self, data, delimiter=CSVDelimiterChoices.AUTO):
107 """
108 Clean CSV-formatted data. The first row will be treated as column headers.
109 """
110 # Determine the CSV dialect
111 if delimiter == CSVDelimiterChoices.AUTO:
112 # This uses a rough heuristic to detect the CSV dialect based on the presence of supported delimiting
113 # characters. If the data is malformed, we'll fall back to the default Excel dialect.
114 delimiters = ''.join(CSV_DELIMITERS.values())
115 try:
116 dialect = csv.Sniffer().sniff(data.strip(), delimiters=delimiters)
117 except csv.Error:
118 dialect = csv.excel
119 elif delimiter in (CSVDelimiterChoices.COMMA, CSVDelimiterChoices.SEMICOLON, CSVDelimiterChoices.PIPE):
120 dialect = csv.excel
121 dialect.delimiter = delimiter
122 elif delimiter == CSVDelimiterChoices.TAB:
123 dialect = csv.excel_tab
124 else:
125 raise forms.ValidationError({
126 'csv_delimiter': _('Invalid CSV delimiter'),
127 })
129 stream = StringIO(data.strip())
130 reader = csv.reader(stream, dialect=dialect)
131 headers, records = parse_csv(reader)
133 # Set CSV headers for reference by the model form
134 headers.pop('id', None)
135 self._csv_headers = headers
137 return records
139 def _clean_json(self, data):
140 """
141 Clean JSON-formatted data. If only a single object is defined, it will be encapsulated as a list.
142 """
143 try:
144 data = json.loads(data)
145 # Accommodate for users entering single objects
146 if type(data) is not list:
147 data = [data]
148 return data
149 except json.decoder.JSONDecodeError as err:
150 raise forms.ValidationError({
151 self.data_field: f"Invalid JSON data: {err}"
152 })
154 def _clean_yaml(self, data):
155 """
156 Clean YAML-formatted data. Data must be either
157 a) A single document comprising a list of dictionaries (each representing an object), or
158 b) Multiple documents, separated with the '---' token
159 """
160 records = []
161 try:
162 for data in yaml.load_all(data, Loader=yaml.SafeLoader):
163 if type(data) is list:
164 records.extend(data)
165 elif type(data) is dict:
166 records.append(data)
167 else:
168 raise forms.ValidationError({
169 self.data_field: _(
170 "Invalid YAML data. Data must be in the form of multiple documents, or a single document "
171 "comprising a list of dictionaries."
172 )
173 })
174 except yaml.error.YAMLError as err:
175 raise forms.ValidationError({
176 self.data_field: f"Invalid YAML data: {err}"
177 })
179 return records