• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

django-import-export / django-import-export / 17400817337

02 Sep 2025 10:32AM UTC coverage: 100.0%. Remained the same
17400817337

push

github

web-flow
[pre-commit.ci] pre-commit autoupdate (#2105)

updates:
- [github.com/adamchainz/django-upgrade: 1.25.0 → 1.27.0](https://github.com/adamchainz/django-upgrade/compare/1.25.0...1.27.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

2303 of 2303 relevant lines covered (100.0%)

4.98 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

100.0
/import_export/admin.py
1
import logging
5✔
2
import warnings
5✔
3

4
import django
5✔
5
from django.conf import settings
5✔
6
from django.contrib import admin, messages
5✔
7
from django.contrib.admin.models import ADDITION, CHANGE, DELETION, LogEntry
5✔
8
from django.contrib.auth import get_permission_codename
5✔
9
from django.contrib.contenttypes.models import ContentType
5✔
10
from django.core.exceptions import FieldError, PermissionDenied
5✔
11
from django.forms import MultipleChoiceField, MultipleHiddenInput
5✔
12
from django.http import HttpResponse, HttpResponseRedirect
5✔
13
from django.shortcuts import render
5✔
14
from django.template.response import TemplateResponse
5✔
15
from django.urls import path, reverse
5✔
16
from django.utils.decorators import method_decorator
5✔
17
from django.utils.module_loading import import_string
5✔
18
from django.utils.translation import gettext_lazy as _
5✔
19
from django.views.decorators.http import require_POST
5✔
20

21
from .formats.base_formats import BINARY_FORMATS
5✔
22
from .forms import ConfirmImportForm, ImportForm, SelectableFieldsExportForm
5✔
23
from .mixins import BaseExportMixin, BaseImportMixin
5✔
24
from .results import RowResult
5✔
25
from .signals import post_export, post_import
5✔
26
from .tmp_storages import TempFolderStorage
5✔
27

28
logger = logging.getLogger(__name__)
5✔
29

30

31
class ImportExportMixinBase:
5✔
32
    def __init__(self, *args, **kwargs):
5✔
33
        super().__init__(*args, **kwargs)
5✔
34
        self.init_change_list_template()
5✔
35

36
    def init_change_list_template(self):
5✔
37
        # Store already set change_list_template to allow users to independently
38
        # customize the change list object tools. This treats the cases where
39
        # `self.change_list_template` is `None` (the default in `ModelAdmin`) or
40
        # where `self.import_export_change_list_template` is `None` as falling
41
        # back on the default templates.
42
        if getattr(self, "change_list_template", None):
5✔
43
            self.ie_base_change_list_template = self.change_list_template
5✔
44
        else:
45
            self.ie_base_change_list_template = "admin/change_list.html"
5✔
46

47
        try:
5✔
48
            self.change_list_template = getattr(
5✔
49
                self, "import_export_change_list_template", None
50
            )
51
        except AttributeError:
5✔
52
            logger.warning("failed to assign change_list_template attribute")
5✔
53

54
        if self.change_list_template is None:
5✔
55
            self.change_list_template = self.ie_base_change_list_template
5✔
56

57
    def get_model_info(self):
5✔
58
        app_label = self.model._meta.app_label
5✔
59
        return (app_label, self.model._meta.model_name)
5✔
60

61
    def changelist_view(self, request, extra_context=None):
5✔
62
        extra_context = extra_context or {}
5✔
63
        extra_context["ie_base_change_list_template"] = (
5✔
64
            self.ie_base_change_list_template
65
        )
66
        return super().changelist_view(request, extra_context)
5✔
67

68

69
class ImportMixin(BaseImportMixin, ImportExportMixinBase):
5✔
70
    """
71
    Import mixin.
72

73
    This is intended to be mixed with django.contrib.admin.ModelAdmin
74
    https://docs.djangoproject.com/en/dev/ref/contrib/admin/
75
    """
76

77
    #: template for change_list view
78
    import_export_change_list_template = "admin/import_export/change_list_import.html"
5✔
79
    #: template for import view
80
    import_template_name = "admin/import_export/import.html"
5✔
81
    #: form class to use for the initial import step
82
    import_form_class = ImportForm
5✔
83
    #: form class to use for the confirm import step
84
    confirm_form_class = ConfirmImportForm
5✔
85
    #: import data encoding
86
    from_encoding = "utf-8-sig"
5✔
87
    #: control which UI elements appear when import errors are displayed.
88
    #: Available options: 'message', 'row', 'traceback'
89
    import_error_display = ("message",)
5✔
90

91
    skip_admin_log = None
5✔
92
    # storage class for saving temporary files
93
    tmp_storage_class = None
5✔
94

95
    def get_skip_admin_log(self):
5✔
96
        if self.skip_admin_log is None:
5✔
97
            return getattr(settings, "IMPORT_EXPORT_SKIP_ADMIN_LOG", False)
5✔
98
        else:
99
            return self.skip_admin_log
5✔
100

101
    def get_tmp_storage_class(self):
5✔
102
        if self.tmp_storage_class is None:
5✔
103
            tmp_storage_class = getattr(
5✔
104
                settings,
105
                "IMPORT_EXPORT_TMP_STORAGE_CLASS",
106
                TempFolderStorage,
107
            )
108
        else:
109
            tmp_storage_class = self.tmp_storage_class
5✔
110

111
        if isinstance(tmp_storage_class, str):
5✔
112
            tmp_storage_class = import_string(tmp_storage_class)
5✔
113
        return tmp_storage_class
5✔
114

115
    def get_tmp_storage_class_kwargs(self):
5✔
116
        """Override this method to provide additional kwargs to temp storage class."""
117
        return {}
5✔
118

119
    def has_import_permission(self, request):
5✔
120
        """
121
        Returns whether a request has import permission.
122
        """
123
        IMPORT_PERMISSION_CODE = getattr(
5✔
124
            settings, "IMPORT_EXPORT_IMPORT_PERMISSION_CODE", None
125
        )
126
        if IMPORT_PERMISSION_CODE is None:
5✔
127
            return True
5✔
128

129
        opts = self.opts
5✔
130
        codename = get_permission_codename(IMPORT_PERMISSION_CODE, opts)
5✔
131
        return request.user.has_perm(f"{opts.app_label}.{codename}")
5✔
132

133
    def get_urls(self):
5✔
134
        urls = super().get_urls()
5✔
135
        info = self.get_model_info()
5✔
136
        my_urls = [
5✔
137
            path(
138
                "process_import/",
139
                self.admin_site.admin_view(self.process_import),
140
                name="%s_%s_process_import" % info,
141
            ),
142
            path(
143
                "import/",
144
                self.admin_site.admin_view(self.import_action),
145
                name="%s_%s_import" % info,
146
            ),
147
        ]
148
        return my_urls + urls
5✔
149

150
    @method_decorator(require_POST)
5✔
151
    def process_import(self, request, **kwargs):
5✔
152
        """
153
        Perform the actual import action (after the user has confirmed the import)
154
        """
155
        if not self.has_import_permission(request):
5✔
156
            raise PermissionDenied
5✔
157

158
        confirm_form = self.create_confirm_form(request)
5✔
159
        if confirm_form.is_valid():
5✔
160
            import_formats = self.get_import_formats()
5✔
161
            input_format = import_formats[int(confirm_form.cleaned_data["format"])](
5✔
162
                encoding=self.from_encoding
163
            )
164
            encoding = None if input_format.is_binary() else self.from_encoding
5✔
165
            tmp_storage_cls = self.get_tmp_storage_class()
5✔
166
            tmp_storage = tmp_storage_cls(
5✔
167
                name=confirm_form.cleaned_data["import_file_name"],
168
                encoding=encoding,
169
                read_mode=input_format.get_read_mode(),
170
                **self.get_tmp_storage_class_kwargs(),
171
            )
172

173
            data = tmp_storage.read()
5✔
174
            dataset = input_format.create_dataset(data)
5✔
175
            result = self.process_dataset(dataset, confirm_form, request, **kwargs)
5✔
176

177
            tmp_storage.remove()
5✔
178

179
            return self.process_result(result, request)
5✔
180
        else:
181
            context = self.admin_site.each_context(request)
5✔
182
            context.update(
5✔
183
                {
184
                    "title": _("Import"),
185
                    "confirm_form": confirm_form,
186
                    "opts": self.model._meta,
187
                    "errors": confirm_form.errors,
188
                }
189
            )
190
            return TemplateResponse(request, [self.import_template_name], context)
5✔
191

192
    def process_dataset(
5✔
193
        self,
194
        dataset,
195
        form,
196
        request,
197
        **kwargs,
198
    ):
199
        res_kwargs = self.get_import_resource_kwargs(request, form=form, **kwargs)
5✔
200
        resource = self.choose_import_resource_class(form, request)(**res_kwargs)
5✔
201
        imp_kwargs = self.get_import_data_kwargs(request=request, form=form, **kwargs)
5✔
202
        imp_kwargs["retain_instance_in_row_result"] = True
5✔
203

204
        return resource.import_data(
5✔
205
            dataset,
206
            dry_run=False,
207
            file_name=form.cleaned_data.get("original_file_name"),
208
            user=request.user,
209
            **imp_kwargs,
210
        )
211

212
    def process_result(self, result, request):
5✔
213
        self.generate_log_entries(result, request)
5✔
214
        self.add_success_message(result, request)
5✔
215
        post_import.send(sender=None, model=self.model)
5✔
216

217
        url = reverse(
5✔
218
            "admin:%s_%s_changelist" % self.get_model_info(),
219
            current_app=self.admin_site.name,
220
        )
221
        return HttpResponseRedirect(url)
5✔
222

223
    def generate_log_entries(self, result, request):
5✔
224
        if not self.get_skip_admin_log():
5✔
225
            # Add imported objects to LogEntry
226
            if django.VERSION >= (5, 1):
5✔
227
                self._log_actions(result, request)
4✔
228
            else:
229
                logentry_map = {
3✔
230
                    RowResult.IMPORT_TYPE_NEW: ADDITION,
231
                    RowResult.IMPORT_TYPE_UPDATE: CHANGE,
232
                    RowResult.IMPORT_TYPE_DELETE: DELETION,
233
                }
234
                content_type_id = ContentType.objects.get_for_model(self.model).pk
3✔
235
                for row in result:
3✔
236
                    if row.import_type in logentry_map:
3✔
237
                        with warnings.catch_warnings():
3✔
238
                            cat = DeprecationWarning
3✔
239
                            warnings.simplefilter("ignore", category=cat)
3✔
240
                            LogEntry.objects.log_action(
3✔
241
                                user_id=request.user.pk,
242
                                content_type_id=content_type_id,
243
                                object_id=row.object_id,
244
                                object_repr=row.object_repr,
245
                                action_flag=logentry_map[row.import_type],
246
                                change_message=_(
247
                                    "%s through import_export" % row.import_type
248
                                ),
249
                            )
250

251
    def add_success_message(self, result, request):
5✔
252
        opts = self.model._meta
5✔
253

254
        success_message = _(
5✔
255
            "Import finished: {} new, {} updated, {} deleted and {} skipped {}."
256
        ).format(
257
            result.totals[RowResult.IMPORT_TYPE_NEW],
258
            result.totals[RowResult.IMPORT_TYPE_UPDATE],
259
            result.totals[RowResult.IMPORT_TYPE_DELETE],
260
            result.totals[RowResult.IMPORT_TYPE_SKIP],
261
            opts.verbose_name_plural,
262
        )
263

264
        messages.success(request, success_message)
5✔
265

266
    def get_import_context_data(self, **kwargs):
5✔
267
        return self.get_context_data(**kwargs)
5✔
268

269
    def get_context_data(self, **kwargs):
5✔
270
        return {}
5✔
271

272
    def create_import_form(self, request):
5✔
273
        """
274
        .. versionadded:: 3.0
275

276
        Return a form instance to use for the 'initial' import step.
277
        This method can be extended to make dynamic form updates to the
278
        form after it has been instantiated. You might also look to
279
        override the following:
280

281
        * :meth:`~import_export.admin.ImportMixin.get_import_form_class`
282
        * :meth:`~import_export.admin.ImportMixin.get_import_form_kwargs`
283
        * :meth:`~import_export.admin.ImportMixin.get_import_form_initial`
284
        * :meth:`~import_export.mixins.BaseImportMixin.get_import_resource_classes`
285
        """
286
        formats = self.get_import_formats()
5✔
287
        form_class = self.get_import_form_class(request)
5✔
288
        kwargs = self.get_import_form_kwargs(request)
5✔
289

290
        return form_class(formats, self.get_import_resource_classes(request), **kwargs)
5✔
291

292
    def get_import_form_class(self, request):
5✔
293
        """
294
        .. versionadded:: 3.0
295

296
        Return the form class to use for the 'import' step. If you only have
297
        a single custom form class, you can set the ``import_form_class``
298
        attribute to change this for your subclass.
299
        """
300
        return self.import_form_class
5✔
301

302
    def get_import_form_kwargs(self, request):
5✔
303
        """
304
        .. versionadded:: 3.0
305

306
        Return a dictionary of values with which to initialize the 'import'
307
        form (including the initial values returned by
308
        :meth:`~import_export.admin.ImportMixin.get_import_form_initial`).
309
        """
310
        return {
5✔
311
            "data": request.POST or None,
312
            "files": request.FILES or None,
313
            "initial": self.get_import_form_initial(request),
314
        }
315

316
    def get_import_form_initial(self, request):
5✔
317
        """
318
        .. versionadded:: 3.0
319

320
        Return a dictionary of initial field values to be provided to the
321
        'import' form.
322
        """
323
        return {}
5✔
324

325
    def create_confirm_form(self, request, import_form=None):
5✔
326
        """
327
        .. versionadded:: 3.0
328

329
        Return a form instance to use for the 'confirm' import step.
330
        This method can be extended to make dynamic form updates to the
331
        form after it has been instantiated. You might also look to
332
        override the following:
333

334
        * :meth:`~import_export.admin.ImportMixin.get_confirm_form_class`
335
        * :meth:`~import_export.admin.ImportMixin.get_confirm_form_kwargs`
336
        * :meth:`~import_export.admin.ImportMixin.get_confirm_form_initial`
337
        """
338
        form_class = self.get_confirm_form_class(request)
5✔
339
        kwargs = self.get_confirm_form_kwargs(request, import_form)
5✔
340
        return form_class(**kwargs)
5✔
341

342
    def get_confirm_form_class(self, request):
5✔
343
        """
344
        .. versionadded:: 3.0
345

346
        Return the form class to use for the 'confirm' import step. If you only
347
        have a single custom form class, you can set the ``confirm_form_class``
348
        attribute to change this for your subclass.
349
        """
350
        return self.confirm_form_class
5✔
351

352
    def get_confirm_form_kwargs(self, request, import_form=None):
5✔
353
        """
354
        .. versionadded:: 3.0
355

356
        Return a dictionary of values with which to initialize the 'confirm'
357
        form (including the initial values returned by
358
        :meth:`~import_export.admin.ImportMixin.get_confirm_form_initial`).
359
        """
360
        if import_form:
5✔
361
            # When initiated from `import_action()`, the 'posted' data
362
            # is for the 'import' form, not this one.
363
            data = None
5✔
364
            files = None
5✔
365
        else:
366
            data = request.POST or None
5✔
367
            files = request.FILES or None
5✔
368

369
        return {
5✔
370
            "data": data,
371
            "files": files,
372
            "initial": self.get_confirm_form_initial(request, import_form),
373
        }
374

375
    def get_confirm_form_initial(self, request, import_form):
5✔
376
        """
377
        .. versionadded:: 3.0
378

379
        Return a dictionary of initial field values to be provided to the
380
        'confirm' form.
381
        """
382
        if import_form is None:
5✔
383
            return {}
5✔
384
        return {
5✔
385
            "import_file_name": import_form.cleaned_data[
386
                "import_file"
387
            ].tmp_storage_name,
388
            "original_file_name": import_form.cleaned_data["import_file"].name,
389
            "format": import_form.cleaned_data["format"],
390
            "resource": import_form.cleaned_data.get("resource", ""),
391
        }
392

393
    def get_import_data_kwargs(self, **kwargs):
5✔
394
        """
395
        Prepare kwargs for import_data.
396
        """
397
        form = kwargs.get("form")
5✔
398
        if form:
5✔
399
            kwargs.pop("form")
5✔
400
            return kwargs
5✔
401
        return kwargs
5✔
402

403
    def write_to_tmp_storage(self, import_file, input_format):
5✔
404
        encoding = None
5✔
405
        if not input_format.is_binary():
5✔
406
            encoding = self.from_encoding
5✔
407

408
        tmp_storage_cls = self.get_tmp_storage_class()
5✔
409
        tmp_storage = tmp_storage_cls(
5✔
410
            encoding=encoding,
411
            read_mode=input_format.get_read_mode(),
412
            **self.get_tmp_storage_class_kwargs(),
413
        )
414
        data = b""
5✔
415
        for chunk in import_file.chunks():
5✔
416
            data += chunk
5✔
417

418
        tmp_storage.save(data)
5✔
419
        return tmp_storage
5✔
420

421
    def add_data_read_fail_error_to_form(self, form, e):
5✔
422
        exc_name = repr(type(e).__name__)
5✔
423
        msg = _(
5✔
424
            "%(exc_name)s encountered while trying to read file. "
425
            "Ensure you have chosen the correct format for the file."
426
        ) % {"exc_name": exc_name}
427
        form.add_error("import_file", msg)
5✔
428

429
    def import_action(self, request, **kwargs):
5✔
430
        """
431
        Perform a dry_run of the import to make sure the import will not
432
        result in errors.  If there are no errors, save the user
433
        uploaded file to a local temp file that will be used by
434
        'process_import' for the actual import.
435
        """
436
        if not self.has_import_permission(request):
5✔
437
            raise PermissionDenied
5✔
438

439
        context = self.get_import_context_data()
5✔
440

441
        import_formats = self.get_import_formats()
5✔
442
        import_form = self.create_import_form(request)
5✔
443
        resources = []
5✔
444
        if request.POST and import_form.is_valid():
5✔
445
            input_format = import_formats[int(import_form.cleaned_data["format"])]()
5✔
446
            if not input_format.is_binary():
5✔
447
                input_format.encoding = self.from_encoding
5✔
448
            import_file = import_form.cleaned_data["import_file"]
5✔
449

450
            if self.is_skip_import_confirm_enabled():
5✔
451
                # This setting means we are going to skip the import confirmation step.
452
                # Go ahead and process the file for import in a transaction
453
                # If there are any errors, we roll back the transaction.
454
                # rollback_on_validation_errors is set to True so that we rollback on
455
                # validation errors. If this is not done validation errors would be
456
                # silently skipped.
457
                data = b""
5✔
458
                for chunk in import_file.chunks():
5✔
459
                    data += chunk
5✔
460
                try:
5✔
461
                    dataset = input_format.create_dataset(data)
5✔
462
                except Exception as e:
5✔
463
                    self.add_data_read_fail_error_to_form(import_form, e)
5✔
464
                if not import_form.errors:
5✔
465
                    result = self.process_dataset(
5✔
466
                        dataset,
467
                        import_form,
468
                        request,
469
                        raise_errors=False,
470
                        rollback_on_validation_errors=True,
471
                        **kwargs,
472
                    )
473
                    if not result.has_errors() and not result.has_validation_errors():
5✔
474
                        return self.process_result(result, request)
5✔
475
                    else:
476
                        context["result"] = result
5✔
477
            else:
478
                # first always write the uploaded file to disk as it may be a
479
                # memory file or else based on settings upload handlers
480
                tmp_storage = self.write_to_tmp_storage(import_file, input_format)
5✔
481
                # allows get_confirm_form_initial() to include both the
482
                # original and saved file names from form.cleaned_data
483
                import_file.tmp_storage_name = tmp_storage.name
5✔
484

485
                try:
5✔
486
                    # then read the file, using the proper format-specific mode
487
                    # warning, big files may exceed memory
488
                    data = tmp_storage.read()
5✔
489
                    dataset = input_format.create_dataset(data)
5✔
490
                except Exception as e:
5✔
491
                    self.add_data_read_fail_error_to_form(import_form, e)
5✔
492
                else:
493
                    if len(dataset) == 0:
5✔
494
                        import_form.add_error(
5✔
495
                            "import_file",
496
                            _(
497
                                "No valid data to import. Ensure your file "
498
                                "has the correct headers or data for import."
499
                            ),
500
                        )
501

502
                if not import_form.errors:
5✔
503
                    # prepare kwargs for import data, if needed
504
                    res_kwargs = self.get_import_resource_kwargs(
5✔
505
                        request, form=import_form, **kwargs
506
                    )
507
                    resource = self.choose_import_resource_class(import_form, request)(
5✔
508
                        **res_kwargs
509
                    )
510
                    resources = [resource]
5✔
511

512
                    # prepare additional kwargs for import_data, if needed
513
                    imp_kwargs = self.get_import_data_kwargs(
5✔
514
                        request=request, form=import_form, **kwargs
515
                    )
516
                    result = resource.import_data(
5✔
517
                        dataset,
518
                        dry_run=True,
519
                        raise_errors=False,
520
                        file_name=import_file.name,
521
                        user=request.user,
522
                        **imp_kwargs,
523
                    )
524
                    context["result"] = result
5✔
525

526
                    if not result.has_errors() and not result.has_validation_errors():
5✔
527
                        context["confirm_form"] = self.create_confirm_form(
5✔
528
                            request, import_form=import_form
529
                        )
530
        else:
531
            res_kwargs = self.get_import_resource_kwargs(
5✔
532
                request=request, form=import_form, **kwargs
533
            )
534
            resource_classes = self.get_import_resource_classes(request)
5✔
535
            resources = [
5✔
536
                resource_class(**res_kwargs) for resource_class in resource_classes
537
            ]
538

539
        context.update(self.admin_site.each_context(request))
5✔
540

541
        context["title"] = _("Import")
5✔
542
        context["form"] = import_form
5✔
543
        context["opts"] = self.model._meta
5✔
544
        context["media"] = self.media + import_form.media
5✔
545
        context["fields_list"] = [
5✔
546
            (
547
                resource.get_display_name(),
548
                [f.column_name for f in resource.get_user_visible_fields()],
549
            )
550
            for resource in resources
551
        ]
552
        context["import_error_display"] = self.import_error_display
5✔
553

554
        request.current_app = self.admin_site.name
5✔
555
        return TemplateResponse(request, [self.import_template_name], context)
5✔
556

557
    def changelist_view(self, request, extra_context=None):
5✔
558
        if extra_context is None:
5✔
559
            extra_context = {}
5✔
560
        extra_context["has_import_permission"] = self.has_import_permission(request)
5✔
561
        return super().changelist_view(request, extra_context)
5✔
562

563
    def _log_actions(self, result, request):
5✔
564
        """
565
        Create appropriate LogEntry instances for the result.
566
        """
567
        rows = {}
4✔
568
        for row in result:
4✔
569
            rows.setdefault(row.import_type, [])
4✔
570
            rows[row.import_type].append(row.instance)
4✔
571

572
        self._create_log_entries(request.user.pk, rows)
4✔
573

574
    def _create_log_entries(self, user_pk, rows):
5✔
575
        logentry_map = {
4✔
576
            RowResult.IMPORT_TYPE_NEW: ADDITION,
577
            RowResult.IMPORT_TYPE_UPDATE: CHANGE,
578
            RowResult.IMPORT_TYPE_DELETE: DELETION,
579
        }
580
        missing = object()
4✔
581
        for import_type, instances in rows.items():
4✔
582
            action_flag = logentry_map.get(import_type, missing)
4✔
583
            if action_flag is not missing:
4✔
584
                self._create_log_entry(
4✔
585
                    user_pk, rows[import_type], import_type, action_flag
586
                )
587

588
    def _create_log_entry(self, user_pk, rows, import_type, action_flag):
5✔
589
        if len(rows) > 0:
4✔
590
            LogEntry.objects.log_actions(
4✔
591
                user_pk,
592
                rows,
593
                action_flag,
594
                change_message=_("%s through import_export" % import_type),
595
                single_object=len(rows) == 1,
596
            )
597

598

599
class ExportMixin(BaseExportMixin, ImportExportMixinBase):
5✔
600
    """
601
    Export mixin.
602

603
    This is intended to be mixed with
604
    `ModelAdmin <https://docs.djangoproject.com/en/stable/ref/contrib/admin/>`_.
605
    """
606

607
    #: template for change_list view
608
    import_export_change_list_template = "admin/import_export/change_list_export.html"
5✔
609
    #: template for export view
610
    export_template_name = "admin/import_export/export.html"
5✔
611
    #: export data encoding
612
    to_encoding = None
5✔
613
    #: Form class to use for the initial export step.
614
    #: Assign to :class:`~import_export.forms.ExportForm` if you would
615
    #: like to disable selectable fields feature.
616
    export_form_class = SelectableFieldsExportForm
5✔
617

618
    def get_urls(self):
5✔
619
        urls = super().get_urls()
5✔
620
        my_urls = [
5✔
621
            path(
622
                "export/",
623
                self.admin_site.admin_view(self.export_action),
624
                name="%s_%s_export" % self.get_model_info(),
625
            ),
626
        ]
627
        return my_urls + urls
5✔
628

629
    def has_export_permission(self, request):
5✔
630
        """
631
        Returns whether a request has export permission.
632
        """
633
        EXPORT_PERMISSION_CODE = getattr(
5✔
634
            settings, "IMPORT_EXPORT_EXPORT_PERMISSION_CODE", None
635
        )
636
        if EXPORT_PERMISSION_CODE is None:
5✔
637
            return True
5✔
638

639
        opts = self.opts
5✔
640
        codename = get_permission_codename(EXPORT_PERMISSION_CODE, opts)
5✔
641
        return request.user.has_perm(f"{opts.app_label}.{codename}")
5✔
642

643
    def get_export_queryset(self, request):
5✔
644
        """
645
        Returns export queryset. The queryset is obtained by calling
646
        ModelAdmin
647
        `get_queryset()
648
        <https://docs.djangoproject.com/en/dev/ref/contrib/admin/#django.contrib.admin.ModelAdmin.get_queryset>`_.
649

650
        Default implementation respects applied search and filters.
651
        """
652
        list_display = self.get_list_display(request)
5✔
653
        list_display_links = self.get_list_display_links(request, list_display)
5✔
654
        list_select_related = self.get_list_select_related(request)
5✔
655
        list_filter = self.get_list_filter(request)
5✔
656
        search_fields = self.get_search_fields(request)
5✔
657
        if self.get_actions(request):
5✔
658
            list_display = ["action_checkbox"] + list(list_display)
5✔
659

660
        ChangeList = self.get_changelist(request)
5✔
661
        changelist_kwargs = {
5✔
662
            "request": request,
663
            "model": self.model,
664
            "list_display": list_display,
665
            "list_display_links": list_display_links,
666
            "list_filter": list_filter,
667
            "date_hierarchy": self.date_hierarchy,
668
            "search_fields": search_fields,
669
            "list_select_related": list_select_related,
670
            "list_per_page": self.list_per_page,
671
            "list_max_show_all": self.list_max_show_all,
672
            "list_editable": self.list_editable,
673
            "model_admin": self,
674
            "sortable_by": self.sortable_by,
675
        }
676
        changelist_kwargs["search_help_text"] = self.search_help_text
5✔
677

678
        class ExportChangeList(ChangeList):
5✔
679
            def get_results(self, request):
5✔
680
                """
681
                Overrides ChangeList.get_results() to bypass default operations like
682
                pagination and result counting, which are not needed for export. This
683
                prevents executing unnecessary COUNT queries during ChangeList
684
                initialization.
685
                """
686
                pass
5✔
687

688
        cl = ExportChangeList(**changelist_kwargs)
5✔
689

690
        # get_queryset() is already called during initialization,
691
        # it is enough to get its results
692
        if hasattr(cl, "queryset"):
5✔
693
            return cl.queryset
5✔
694

695
        # Fallback in case the ChangeList doesn't have queryset attribute set
696
        return cl.get_queryset(request)
5✔
697

698
    def get_export_data(self, file_format, request, queryset, **kwargs):
5✔
699
        """
700
        Returns file_format representation for given queryset.
701
        """
702
        if not self.has_export_permission(request):
5✔
703
            raise PermissionDenied
5✔
704

705
        force_native_type = type(file_format) in BINARY_FORMATS
5✔
706
        data = self.get_data_for_export(
5✔
707
            request,
708
            queryset,
709
            force_native_type=force_native_type,
710
            **kwargs,
711
        )
712
        export_data = file_format.export_data(data)
5✔
713
        encoding = kwargs.get("encoding")
5✔
714
        if not file_format.is_binary() and encoding:
5✔
715
            export_data = export_data.encode(encoding)
5✔
716
        return export_data
5✔
717

718
    def get_export_context_data(self, **kwargs):
5✔
719
        return self.get_context_data(**kwargs)
5✔
720

721
    def get_context_data(self, **kwargs):
5✔
722
        return {}
5✔
723

724
    def get_export_form_class(self):
5✔
725
        """
726
        Get the form class used to read the export format.
727
        """
728
        return self.export_form_class
5✔
729

730
    def export_action(self, request):
5✔
731
        """
732
        Handles the default workflow for both the export form and the
733
        export of data to file.
734
        """
735
        if not self.has_export_permission(request):
5✔
736
            raise PermissionDenied
5✔
737

738
        form_type = self.get_export_form_class()
5✔
739
        formats = self.get_export_formats()
5✔
740
        queryset = self.get_export_queryset(request)
5✔
741
        if self.is_skip_export_form_enabled():
5✔
742
            return self._do_file_export(formats[0](), request, queryset)
5✔
743

744
        form = form_type(
5✔
745
            formats,
746
            self.get_export_resource_classes(request),
747
            data=request.POST or None,
748
        )
749
        if request.POST and "export_items" in request.POST:
5✔
750
            # this field is instantiated if the export is POSTed from the
751
            # 'action' drop down
752
            form.fields["export_items"] = MultipleChoiceField(
5✔
753
                widget=MultipleHiddenInput,
754
                required=False,
755
                choices=[(pk, pk) for pk in self.get_valid_export_item_pks(request)],
756
            )
757
        if form.is_valid():
5✔
758
            file_format = formats[int(form.cleaned_data["format"])]()
5✔
759

760
            if "export_items" in form.changed_data:
5✔
761
                # this request has arisen from an Admin UI action
762
                # export item pks are stored in form data
763
                # so generate the queryset from the stored pks
764
                queryset = queryset.filter(pk__in=form.cleaned_data["export_items"])
5✔
765

766
            try:
5✔
767
                return self._do_file_export(
5✔
768
                    file_format, request, queryset, export_form=form
769
                )
770
            except (ValueError, FieldError) as e:
5✔
771
                messages.error(request, str(e))
5✔
772

773
        context = self.init_request_context_data(request, form)
5✔
774
        request.current_app = self.admin_site.name
5✔
775
        return TemplateResponse(request, [self.export_template_name], context=context)
5✔
776

777
    def get_valid_export_item_pks(self, request):
5✔
778
        """
779
        DEPRECATED: This method is deprecated and will be removed in the future.
780
        Overwrite get_queryset() or get_export_queryset() instead.
781

782
        Returns a list of valid pks for export.
783
        This is used to validate which objects can be exported when exports are
784
        triggered from the Admin UI 'action' dropdown.
785
        This can be overridden to filter returned pks for performance and/or security
786
        reasons.
787

788
        :param request: The request object.
789
        :returns: a list of valid pks (by default is all pks in table).
790
        """
791
        cls = self.__class__
5✔
792
        warnings.warn(
5✔
793
            "The 'get_valid_export_item_pks()' method in "
794
            f"{cls.__module__}.{cls.__qualname__} "
795
            "is deprecated and will "
796
            "be removed in a future release",
797
            DeprecationWarning,
798
        )
799
        return self.model.objects.all().values_list("pk", flat=True)
5✔
800

801
    def changelist_view(self, request, extra_context=None):
5✔
802
        if extra_context is None:
5✔
803
            extra_context = {}
5✔
804
        extra_context["has_export_permission"] = self.has_export_permission(request)
5✔
805
        return super().changelist_view(request, extra_context)
5✔
806

807
    def get_export_filename(self, request, queryset, file_format):
5✔
808
        return super().get_export_filename(file_format)
5✔
809

810
    def init_request_context_data(self, request, form):
5✔
811
        context = self.get_export_context_data()
5✔
812
        context.update(self.admin_site.each_context(request))
5✔
813
        context["title"] = _("Export")
5✔
814
        context["form"] = form
5✔
815
        context["opts"] = self.model._meta
5✔
816
        context["fields_list"] = [
5✔
817
            (
818
                res.get_display_name(),
819
                [
820
                    field.column_name
821
                    for field in res(
822
                        **self.get_export_resource_kwargs(request)
823
                    ).get_user_visible_fields()
824
                ],
825
            )
826
            for res in self.get_export_resource_classes(request)
827
        ]
828
        return context
5✔
829

830
    def _do_file_export(self, file_format, request, queryset, export_form=None):
5✔
831
        export_data = self.get_export_data(
5✔
832
            file_format,
833
            request,
834
            queryset,
835
            encoding=self.to_encoding,
836
            export_form=export_form,
837
        )
838
        content_type = file_format.get_content_type()
5✔
839
        response = HttpResponse(export_data, content_type=content_type)
5✔
840
        response["Content-Disposition"] = 'attachment; filename="{}"'.format(
5✔
841
            self.get_export_filename(request, queryset, file_format),
842
        )
843
        post_export.send(sender=None, model=self.model)
5✔
844
        return response
5✔
845

846

847
class ImportExportMixin(ImportMixin, ExportMixin):
5✔
848
    """
849
    Import and export mixin.
850
    """
851

852
    #: template for change_list view
853
    import_export_change_list_template = (
5✔
854
        "admin/import_export/change_list_import_export.html"
855
    )
856

857

858
class ImportExportModelAdmin(ImportExportMixin, admin.ModelAdmin):
5✔
859
    """
860
    Subclass of ModelAdmin with import/export functionality.
861
    """
862

863

864
class ExportActionMixin(ExportMixin):
5✔
865
    """
866
    Mixin with export functionality implemented as an admin action.
867
    """
868

869
    #: template for change form
870
    change_form_template = "admin/import_export/change_form.html"
5✔
871

872
    #: Flag to indicate whether to show 'export' button on change form
873
    show_change_form_export = True
5✔
874

875
    # This action will receive a selection of items as a queryset,
876
    # store them in the context, and then render the 'export' admin form page,
877
    # so that users can select file format and resource
878

879
    def change_view(self, request, object_id, form_url="", extra_context=None):
5✔
880
        extra_context = extra_context or {}
5✔
881
        extra_context["show_change_form_export"] = (
5✔
882
            self.show_change_form_export and self.has_export_permission(request)
883
        )
884
        return super().change_view(
5✔
885
            request,
886
            object_id,
887
            form_url,
888
            extra_context=extra_context,
889
        )
890

891
    def response_change(self, request, obj):
5✔
892
        # called if the export is triggered from the instance detail page.
893
        if "_export-item" in request.POST:
5✔
894
            return self.export_admin_action(
5✔
895
                request, self.model.objects.filter(pk=obj.pk)
896
            )
897
        return super().response_change(request, obj)
5✔
898

899
    def export_admin_action(self, request, queryset):
5✔
900
        """
901
        Action runs on POST from instance action menu (if enabled).
902
        """
903
        formats = self.get_export_formats()
5✔
904
        if self.is_skip_export_form_from_action_enabled():
5✔
905
            file_format = formats[0]()
5✔
906
            return self._do_file_export(file_format, request, queryset)
5✔
907

908
        form_type = self.get_export_form_class()
5✔
909
        formats = self.get_export_formats()
5✔
910
        export_items = list(queryset.values_list("pk", flat=True))
5✔
911
        form = form_type(
5✔
912
            formats=formats,
913
            resources=self.get_export_resource_classes(request),
914
            initial={"export_items": export_items},
915
        )
916
        # selected items are to be stored as a hidden input on the form
917
        form.fields["export_items"] = MultipleChoiceField(
5✔
918
            widget=MultipleHiddenInput, required=False, choices=export_items
919
        )
920
        context = self.init_request_context_data(request, form)
5✔
921

922
        # this is necessary to render the FORM action correctly
923
        # i.e. so the POST goes to the correct URL
924
        export_url = reverse(
5✔
925
            "%s:%s_%s_export"
926
            % (
927
                self.admin_site.name,
928
                self.model._meta.app_label,
929
                self.model._meta.model_name,
930
            )
931
        )
932
        context["export_url"] = export_url
5✔
933

934
        return render(request, "admin/import_export/export.html", context=context)
5✔
935

936
    def get_actions(self, request):
5✔
937
        """
938
        Adds the export action to the list of available actions.
939
        """
940
        actions = super().get_actions(request)
5✔
941
        if self.has_export_permission(request):
5✔
942
            actions.update(
5✔
943
                export_admin_action=(
944
                    type(self).export_admin_action,
945
                    "export_admin_action",
946
                    _("Export selected %(verbose_name_plural)s"),
947
                )
948
            )
949
        return actions
5✔
950

951

952
class ExportActionModelAdmin(ExportActionMixin, admin.ModelAdmin):
5✔
953
    """
954
    Subclass of ModelAdmin with export functionality implemented as an
955
    admin action.
956
    """
957

958

959
class ImportExportActionModelAdmin(ImportMixin, ExportActionModelAdmin):
5✔
960
    """
961
    Subclass of ExportActionModelAdmin with import/export functionality.
962
    Export functionality is implemented as an admin action.
963
    """
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc