Coverage for src/backend/InvenTree/data_exporter/mixins.py: 23%

201 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 17:47 +0000

1"""Mixin classes for the exporter app.""" 

2 

3from collections import OrderedDict 

4from typing import Any 

5 

6from django.core.exceptions import ValidationError 

7from django.core.files.base import ContentFile 

8from django.utils.translation import gettext_lazy as _ 

9 

10import structlog 

11import tablib 

12from rest_framework import serializers 

13from rest_framework.response import Response 

14from taggit.serializers import TagListSerializerField 

15 

16import data_exporter.serializers 

17import data_exporter.tasks 

18import InvenTree.exceptions 

19from common.models import DataOutput 

20from InvenTree.helpers import str2bool 

21from InvenTree.tasks import offload_task 

22from plugin import PluginMixinEnum, registry 

23 

24logger = structlog.get_logger('inventree') 

25 

26 

27class DataExportSerializerMixin: 

28 """Mixin class for adding data export functionality to a DRF serializer. 

29 

30 Provides generic functionality to take the output of a serializer and export it to a file. 

31 

32 Attributes: 

33 export_only_fields: List of field names which are only used during data export 

34 export_exclude_fields: List of field names which are excluded during data export 

35 export_child_fields: List of child fields which are exported (using dot notation) 

36 """ 

37 

38 export_only_fields = [] 

39 export_exclude_fields = [] 

40 export_child_fields = [] 

41 

42 def get_export_only_fields(self, **kwargs) -> list: 

43 """Return the list of field names which are only used during data export.""" 

44 return self.export_only_fields 

45 

46 def get_export_exclude_fields(self, **kwargs) -> list: 

47 """Return the list of field names which are excluded during data export.""" 

48 return self.export_exclude_fields 

49 

50 def __init__(self, *args, **kwargs): 

51 """Initialise the DataExportSerializerMixin. 

52 

53 Determine if the serializer is being used for data export, 

54 and if so, adjust the serializer fields accordingly. 

55 """ 

56 self._exporting_data = exporting = kwargs.pop('exporting', False) 

57 

58 super().__init__(*args, **kwargs) 

59 

60 # Cache the request object 

61 self.request = self.context.get('request') 

62 

63 if exporting: 63 ↛ 65line 63 didn't jump to line 65 because the condition on line 63 was never true

64 # Exclude fields which are not required for data export 

65 for field in self.get_export_exclude_fields(**kwargs): 

66 self.fields.pop(field, None) 

67 else: 

68 # Exclude fields which are only used for data export 

69 for field in self.get_export_only_fields(**kwargs): 

70 self.fields.pop(field, None) 

71 

72 def get_exportable_fields(self) -> dict: 

73 """Return a dict of fields which can be exported against this serializer instance. 

74 

75 Note: Any fields which should be excluded from export have already been removed 

76 

77 Returns: 

78 dict: A dictionary of field names and field objects 

79 """ 

80 fields = {} 

81 

82 if meta := getattr(self, 'Meta', None): 

83 write_only_fields = getattr(meta, 'write_only_fields', []) 

84 else: 

85 write_only_fields = [] 

86 

87 for name, field in self.fields.items(): 

88 # Skip write-only fields 

89 if getattr(field, 'write_only', False) or name in write_only_fields: 

90 continue 

91 

92 # Skip tags fields 

93 # TODO: Implement tag field export support 

94 if issubclass(field.__class__, TagListSerializerField): 

95 continue 

96 

97 # Top-level serializer fields can be exported with dot notation 

98 if issubclass(field.__class__, serializers.Serializer): 

99 fields.update(self.get_child_fields(name, field)) 

100 continue 

101 

102 fields[name] = field 

103 

104 return fields 

105 

106 def get_child_fields(self, field_name: str, field) -> dict: 

107 """Return a dictionary of child fields for a given field. 

108 

109 Only child fields which match the 'export_child_fields' list will be returned. 

110 """ 

111 child_fields = {} 

112 

113 if sub_fields := getattr(field, 'fields', None): 

114 for sub_name, sub_field in sub_fields.items(): 

115 name = f'{field_name}.{sub_name}' 

116 

117 if name in self.export_child_fields: 

118 sub_field.parent_field = field 

119 child_fields[name] = sub_field 

120 

121 return child_fields 

122 

123 @classmethod 

124 def arrange_export_headers(cls, headers: list) -> list: 

125 """Optional method to arrange the export headers. 

126 

127 By default, the headers are returned in the order they are provided. 

128 """ 

129 return headers 

130 

131 def get_nested_value(self, row: dict, key: str) -> Any: 

132 """Get a nested value from a dictionary. 

133 

134 This method allows for dot notation to access nested fields. 

135 

136 Arguments: 

137 row: The dictionary to extract the value from 

138 key: The key to extract 

139 

140 Returns: 

141 any: The extracted value 

142 """ 

143 keys = key.split('.') 

144 

145 value = row 

146 

147 for key in keys: 

148 if not value: 

149 break 

150 

151 if not key: 

152 continue 

153 

154 value = value.get(key, None) 

155 

156 return value 

157 

158 def generate_headers(self) -> OrderedDict: 

159 """Generate a list of default headers for the exported data. 

160 

161 Returns an ordered dict of field names and their corresponding human-readable labels. 

162 

163 e.g. 

164 

165 { 

166 'id': 'ID', 

167 'name': 'Name', 

168 ... 

169 } 

170 

171 """ 

172 fields = self.get_exportable_fields() 

173 field_names = self.arrange_export_headers(list(fields.keys())) 

174 

175 headers = OrderedDict() 

176 

177 for field_name in field_names: 

178 field = fields[field_name] 

179 

180 label = getattr(field, 'label', field_name) 

181 

182 if parent := getattr(field, 'parent_field', None): 

183 label = f'{parent.label}.{label}' 

184 

185 headers[field_name] = label 

186 

187 return headers 

188 

189 def export_to_file(self, data, headers: OrderedDict, file_format): 

190 """Export the queryset to a file in the specified format. 

191 

192 Arguments: 

193 queryset: The queryset to export 

194 data: The serialized dataset to export 

195 headers: The headers to use for the exported data {field: label} 

196 file_format: The file format to export to 

197 

198 Returns: 

199 File object containing the exported data 

200 """ 

201 field_names = list(headers.keys()) 

202 field_headers = list(headers.values()) 

203 

204 # Create a new dataset with the provided header labels 

205 dataset = tablib.Dataset(headers=field_headers) 

206 

207 for row in data: 

208 dataset.append([self.get_nested_value(row, f) for f in field_names]) 

209 

210 return dataset.export(file_format) 

211 

212 

213class DataExportViewMixin: 

214 """An API view mixin for directly exporting selected data. 

215 

216 To perform a data export against an API endpoint which inherits from this mixin, 

217 perform a GET request with 'export=True'. 

218 

219 This will run validation against the DataExportOptionsSerializer. 

220 

221 Once the export options have been validated, a new DataOutput object will be created, 

222 and this will be returned to the client (including a download link to the exported file). 

223 """ 

224 

225 def is_exporting(self) -> bool: 

226 """Determine if the view is currently exporting data.""" 

227 if request := getattr(self, 'request', None): 227 ↛ 232line 227 didn't jump to line 232 because the condition on line 227 was always true

228 return str2bool( 

229 request.data.get('export') or request.query_params.get('export') 

230 ) 

231 

232 return False 

233 

234 def get_plugin(self, plugin_slug=None): 

235 """Return the plugin instance associated with the export request. 

236 

237 Arguments: 

238 plugin_slug: The slug of the plugin to use for exporting the data (optional) 

239 """ 

240 PLUGIN_KEY = 'export_plugin' 

241 

242 if not plugin_slug: 

243 if request := getattr(self, 'request', None): 

244 plugin_slug = request.data.get(PLUGIN_KEY) or request.query_params.get( 

245 PLUGIN_KEY 

246 ) 

247 

248 if plugin_slug: 

249 return registry.get_plugin( 

250 plugin_slug, active=True, with_mixin=PluginMixinEnum.EXPORTER 

251 ) 

252 

253 return None 

254 

255 def get_serializer(self, *args, **kwargs): 

256 """Return the serializer instance for the view. 

257 

258 - Only applies for OPTIONS or GET requests 

259 - OPTIONS requests to determine plugin serializer options 

260 - GET request to perform the data export 

261 - If the view is exporting data, return the DataExportOptionsSerializer. 

262 - Otherwise, return the default serializer. 

263 """ 

264 exporting = kwargs.pop('exporting', None) 

265 

266 if exporting is None: 266 ↛ 270line 266 didn't jump to line 270 because the condition on line 266 was always true

267 method = str(getattr(self.request, 'method', '')).lower() 

268 exporting = method in ['options', 'get'] and self.is_exporting() 

269 

270 if exporting: 270 ↛ 272line 270 didn't jump to line 272 because the condition on line 270 was never true

271 # Override kwargs when initializing the DataExportOptionsSerializer 

272 export_kwargs = { 

273 'plugin': self.get_plugin(), 

274 'request': self.request, 

275 'data': kwargs.get('data'), 

276 'context': kwargs.get('context'), 

277 } 

278 

279 # Get the base model associated with this view 

280 try: 

281 serializer_class = self.get_serializer_class() 

282 export_kwargs['serializer_class'] = serializer_class 

283 export_kwargs['model_class'] = serializer_class.Meta.model 

284 export_kwargs['view_class'] = self.__class__ 

285 except AttributeError: 

286 # If the serializer class is not available, set to None 

287 export_kwargs['serializer_class'] = None 

288 export_kwargs['model_class'] = None 

289 export_kwargs['view_class'] = None 

290 

291 return data_exporter.serializers.DataExportOptionsSerializer( 

292 *args, **export_kwargs 

293 ) 

294 else: 

295 return super().get_serializer(*args, **kwargs) 

296 

297 def export_data( 

298 self, 

299 export_plugin, 

300 export_format: str, 

301 export_context: dict, 

302 output: DataOutput, 

303 ): 

304 """Export the data in the specified format. 

305 

306 Arguments: 

307 export_plugin: The plugin instance to use for exporting the data. If not provided, the default exporter is used 

308 export_format: The file format to export the data in 

309 export_context: Additional context data to pass to the plugin 

310 output: The DataOutput object to write to 

311 

312 - By default, uses the provided serializer to generate the data, and return it as a file download. 

313 - If a plugin is specified, the plugin can be used to augment or replace the export functionality. 

314 """ 

315 if export_plugin is None: 

316 from plugin.registry import registry 

317 

318 export_plugin = registry.get_plugin('inventree-exporter') 

319 

320 # Get the base serializer class for the view 

321 serializer_class = self.get_serializer_class() 

322 

323 if not issubclass(serializer_class, DataExportSerializerMixin): 

324 raise ValidationError( 

325 'Serializer class must inherit from DataExportSerializerMixin' 

326 ) 

327 

328 export_error = _('Error occurred during data export') 

329 

330 context = self.get_serializer_context() 

331 

332 # Perform initial filtering of the queryset, based on the query parameters 

333 queryset = self.filter_queryset(self.get_queryset()) 

334 

335 # Perform additional filtering, as per the provided plugin 

336 try: 

337 queryset = export_plugin.filter_queryset(queryset) 

338 except Exception: 

339 InvenTree.exceptions.log_error('filter_queryset', plugin=export_plugin.slug) 

340 raise ValidationError(export_error) 

341 

342 # Update the output instance with the total number of items to export 

343 output.total = queryset.count() 

344 output.save() 

345 request = context.get('request', None) 

346 

347 if request: 

348 query_params = getattr(request, 'query_params', {}) 

349 context.update(**query_params) 

350 context['request'] = request 

351 

352 data = None 

353 serializer = serializer_class(context=context, exporting=True) 

354 serializer.initial_data = queryset 

355 

356 # Construct 'default' headers (note: may be overridden by plugin) 

357 headers = serializer.generate_headers() 

358 

359 # Generate a filename for the exported data (implemented by the plugin) 

360 try: 

361 filename = export_plugin.generate_filename( 

362 serializer_class.Meta.model, export_format 

363 ) 

364 except Exception as e: 

365 InvenTree.exceptions.log_error( 

366 'generate_filename', plugin=export_plugin.slug 

367 ) 

368 

369 output.mark_failure(error=str(e)) 

370 

371 raise ValidationError(export_error) 

372 

373 # The provided plugin is responsible for exporting the data 

374 # The returned data *must* be a list of dict objects 

375 try: 

376 data = export_plugin.export_data( 

377 queryset, 

378 serializer_class, 

379 headers, 

380 export_context, 

381 output, 

382 serializer_context=context, 

383 ) 

384 

385 except Exception as e: 

386 InvenTree.exceptions.log_error('export_data', plugin=export_plugin.slug) 

387 

388 # Log the error against the output object 

389 output.mark_failure(error=str(e)) 

390 

391 raise ValidationError(export_error) 

392 

393 if not isinstance(data, list): 

394 raise ValidationError( 

395 _('Data export plugin returned incorrect data format') 

396 ) 

397 

398 # Augment / update the headers (if required) 

399 if hasattr(export_plugin, 'update_headers'): 

400 try: 

401 headers = export_plugin.update_headers(headers, export_context) 

402 except Exception as e: 

403 InvenTree.exceptions.log_error( 

404 'update_headers', plugin=export_plugin.slug 

405 ) 

406 

407 output.mark_failure(error=str(e)) 

408 

409 raise ValidationError(export_error) 

410 

411 # Now, export the data to file 

412 try: 

413 datafile = serializer.export_to_file(data, headers, export_format) 

414 except Exception as e: 

415 InvenTree.exceptions.log_error('export_to_file', plugin=export_plugin.slug) 

416 output.mark_failure(error=str(e)) 

417 raise ValidationError(_('Error occurred during data export')) 

418 

419 # Update the output object with the exported data 

420 output.mark_complete(output=ContentFile(datafile, filename)) 

421 

422 def get(self, request, *args, **kwargs): 

423 """Override the GET method to determine export options.""" 

424 from common.serializers import DataOutputSerializer 

425 

426 # If we are not exporting data, return the default response 

427 if self.is_exporting(): 427 ↛ 431line 427 didn't jump to line 431 because the condition on line 427 was never true

428 # Determine if the export options are valid 

429 

430 # Extract the export options from the provided query parameters 

431 export_options = {} 

432 

433 for key in request.query_params: 

434 if key.startswith('export_'): 

435 export_options[key] = request.query_params.get(key) 

436 

437 # Construct the options serializer with the provided data 

438 serializer = self.get_serializer(exporting=True, data=export_options) 

439 

440 serializer.is_valid(raise_exception=True) 

441 serializer_data = serializer.validated_data 

442 

443 export_format = serializer_data.pop('export_format', 'csv') 

444 plugin_slug = serializer_data.pop('export_plugin', 'inventree-exporter') 

445 export_plugin = self.get_plugin(plugin_slug) 

446 

447 export_context = {} 

448 

449 # Also run the data against the plugin serializer 

450 if export_plugin: 

451 if hasattr(export_plugin, 'get_export_options_serializer'): 

452 if plugin_serializer := export_plugin.get_export_options_serializer( 

453 data=export_options 

454 ): 

455 plugin_serializer.is_valid(raise_exception=True) 

456 export_context = plugin_serializer.validated_data 

457 

458 user = getattr(request, 'user', None) 

459 

460 # Add in extra context data for the plugin 

461 export_context['user'] = user 

462 

463 # Create an output object to export against 

464 output = DataOutput.objects.create( 

465 user=user if user and user.is_authenticated else None, 

466 total=0, # Note: this should get updated by the export task 

467 progress=0, 

468 complete=False, 

469 output_type=DataOutput.DataOutputTypes.EXPORT, 

470 plugin=export_plugin.slug, 

471 output=None, 

472 ) 

473 

474 # Offload the export task to a background worker 

475 # This is to avoid blocking the web server 

476 # Note: The export task will loop back and call the 'export_data' method on this class 

477 offload_task( 

478 data_exporter.tasks.export_data, 

479 self.__class__, 

480 request.user.id, 

481 request.query_params, 

482 plugin_slug, 

483 export_format, 

484 export_context, 

485 output.id, 

486 group='exporter', 

487 ) 

488 

489 output.refresh_from_db() 

490 

491 # Return a response to the frontend 

492 return Response(DataOutputSerializer(output).data, status=200) 

493 

494 return super().get(request, *args, **kwargs)