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

django-import-export / django-import-export / 18971559368

31 Oct 2025 11:45AM UTC coverage: 99.614% (-0.3%) from 99.957%
18971559368

push

github

web-flow
Fix test export filter preservation (#2130)

* corrected release docs

* Fix test_export_action_filter_preservation_end_to_end by adding form prefix

The test was failing because it was missing the required form field prefix
when submitting export form data. The export_action view expects fields like
'django-import-export-format' and 'django-import-export-export_items', but
the test was sending unprefixed field names, causing form validation to fail
and return an HTML response instead of the expected CSV export.

Added call to _prepend_form_prefix(export_data) to match the pattern used
consistently throughout the test file (lines 100, 136, 189, 202).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* added service definition for mysql

* updated CI config

---------

Co-authored-by: Claude <noreply@anthropic.com>

2320 of 2329 relevant lines covered (99.61%)

3.98 hits per line

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

97.69
/import_export/admin.py
1
import logging
4✔
2
import warnings
4✔
3
from urllib.parse import urlencode
4✔
4

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

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

30
logger = logging.getLogger(__name__)
4✔
31

32

33
class ImportExportMixinBase:
4✔
34
    def __init__(self, *args, **kwargs):
4✔
35
        super().__init__(*args, **kwargs)
4✔
36
        self.init_change_list_template()
4✔
37

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

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

56
        if self.change_list_template is None:
4✔
57
            self.change_list_template = self.ie_base_change_list_template
4✔
58

59
    def get_model_info(self):
4✔
60
        app_label = self.model._meta.app_label
4✔
61
        return (app_label, self.model._meta.model_name)
4✔
62

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

70

71
class ImportMixin(BaseImportMixin, ImportExportMixinBase):
4✔
72
    """
73
    Import mixin.
74

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

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

93
    skip_admin_log = None
4✔
94
    # storage class for saving temporary files
95
    tmp_storage_class = None
4✔
96

97
    def get_skip_admin_log(self):
4✔
98
        if self.skip_admin_log is None:
4✔
99
            return getattr(settings, "IMPORT_EXPORT_SKIP_ADMIN_LOG", False)
4✔
100
        else:
101
            return self.skip_admin_log
×
102

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

113
        if isinstance(tmp_storage_class, str):
4✔
114
            tmp_storage_class = import_string(tmp_storage_class)
4✔
115
        return tmp_storage_class
4✔
116

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

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

131
        opts = self.opts
4✔
132
        codename = get_permission_codename(IMPORT_PERMISSION_CODE, opts)
4✔
133
        return request.user.has_perm(f"{opts.app_label}.{codename}")
4✔
134

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

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

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

175
            data = tmp_storage.read()
4✔
176
            dataset = input_format.create_dataset(data)
4✔
177
            result = self.process_dataset(dataset, confirm_form, request, **kwargs)
4✔
178

179
            tmp_storage.remove()
4✔
180

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

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

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

214
    def process_result(self, result, request):
4✔
215
        self.generate_log_entries(result, request)
4✔
216
        self.add_success_message(result, request)
4✔
217
        post_import.send(sender=None, model=self.model)
4✔
218

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

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

253
    def add_success_message(self, result, request):
4✔
254
        opts = self.model._meta
4✔
255

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

266
        messages.success(request, success_message)
4✔
267

268
    def get_import_context_data(self, **kwargs):
4✔
269
        return self.get_context_data(**kwargs)
4✔
270

271
    def get_context_data(self, **kwargs):
4✔
272
        return {}
4✔
273

274
    def create_import_form(self, request):
4✔
275
        """
276
        .. versionadded:: 3.0
277

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

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

292
        return form_class(formats, self.get_import_resource_classes(request), **kwargs)
4✔
293

294
    def get_import_form_class(self, request):
4✔
295
        """
296
        .. versionadded:: 3.0
297

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

304
    def get_import_form_kwargs(self, request):
4✔
305
        """
306
        .. versionadded:: 3.0
307

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

318
    def get_import_form_initial(self, request):
4✔
319
        """
320
        .. versionadded:: 3.0
321

322
        Return a dictionary of initial field values to be provided to the
323
        'import' form.
324
        """
325
        return {}
4✔
326

327
    def create_confirm_form(self, request, import_form=None):
4✔
328
        """
329
        .. versionadded:: 3.0
330

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

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

344
    def get_confirm_form_class(self, request):
4✔
345
        """
346
        .. versionadded:: 3.0
347

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

354
    def get_confirm_form_kwargs(self, request, import_form=None):
4✔
355
        """
356
        .. versionadded:: 3.0
357

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

371
        return {
4✔
372
            "data": data,
373
            "files": files,
374
            "initial": self.get_confirm_form_initial(request, import_form),
375
        }
376

377
    def get_confirm_form_initial(self, request, import_form):
4✔
378
        """
379
        .. versionadded:: 3.0
380

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

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

405
    def write_to_tmp_storage(self, import_file, input_format):
4✔
406
        encoding = None
4✔
407
        if not input_format.is_binary():
4✔
408
            encoding = self.from_encoding
4✔
409

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

420
        tmp_storage.save(data)
4✔
421
        return tmp_storage
4✔
422

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

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

441
        context = self.get_import_context_data()
4✔
442

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

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

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

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

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

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

541
        context.update(self.admin_site.each_context(request))
4✔
542

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

556
        request.current_app = self.admin_site.name
4✔
557
        return TemplateResponse(request, [self.import_template_name], context)
4✔
558

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

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

574
        self._create_log_entries(request.user.pk, rows)
4✔
575

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

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

600

601
class ExportMixin(BaseExportMixin, ImportExportMixinBase):
4✔
602
    """
603
    Export mixin.
604

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

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

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

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

641
        opts = self.opts
4✔
642
        codename = get_permission_codename(EXPORT_PERMISSION_CODE, opts)
4✔
643
        return request.user.has_perm(f"{opts.app_label}.{codename}")
4✔
644

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

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

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

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

690
        cl = ExportChangeList(**changelist_kwargs)
4✔
691

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

697
        # Fallback in case the ChangeList doesn't have queryset attribute set
698
        return cl.get_queryset(request)
4✔
699

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

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

720
    def get_export_context_data(self, **kwargs):
4✔
721
        return self.get_context_data(**kwargs)
4✔
722

723
    def get_context_data(self, **kwargs):
4✔
724
        return {}
4✔
725

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

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

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

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

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

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

775
        context = self.init_request_context_data(request, form)
4✔
776
        request.current_app = self.admin_site.name
4✔
777
        return TemplateResponse(request, [self.export_template_name], context=context)
4✔
778

779
    def changelist_view(self, request, extra_context=None):
4✔
780
        if extra_context is None:
4✔
781
            extra_context = {}
4✔
782
        extra_context["has_export_permission"] = self.has_export_permission(request)
4✔
783
        return super().changelist_view(request, extra_context)
4✔
784

785
    def get_export_filename(self, request, queryset, file_format):
4✔
786
        return super().get_export_filename(file_format)
4✔
787

788
    def init_request_context_data(self, request, form):
4✔
789
        context = self.get_export_context_data()
4✔
790
        context.update(self.admin_site.each_context(request))
4✔
791
        context["title"] = _("Export")
4✔
792
        context["form"] = form
4✔
793
        context["opts"] = self.model._meta
4✔
794
        context["fields_list"] = [
4✔
795
            (
796
                res.get_display_name(),
797
                [
798
                    field.column_name
799
                    for field in res(
800
                        **self.get_export_resource_kwargs(request)
801
                    ).get_user_visible_export_fields()
802
                ],
803
            )
804
            for res in self.get_export_resource_classes(request)
805
        ]
806
        return context
4✔
807

808
    def _do_file_export(self, file_format, request, queryset, export_form=None):
4✔
809
        export_data = self.get_export_data(
4✔
810
            file_format,
811
            request,
812
            queryset,
813
            encoding=self.to_encoding,
814
            export_form=export_form,
815
        )
816
        content_type = file_format.get_content_type()
4✔
817
        response = HttpResponse(export_data, content_type=content_type)
4✔
818
        response["Content-Disposition"] = 'attachment; filename="{}"'.format(
4✔
819
            self.get_export_filename(request, queryset, file_format),
820
        )
821
        post_export.send(sender=None, model=self.model)
4✔
822
        return response
4✔
823

824

825
class ImportExportMixin(ImportMixin, ExportMixin):
4✔
826
    """
827
    Import and export mixin.
828
    """
829

830
    #: template for change_list view
831
    import_export_change_list_template = (
4✔
832
        "admin/import_export/change_list_import_export.html"
833
    )
834

835

836
class ImportExportModelAdmin(ImportExportMixin, admin.ModelAdmin):
4✔
837
    """
838
    Subclass of ModelAdmin with import/export functionality.
839
    """
840

841

842
class ExportActionMixin(ExportMixin):
4✔
843
    """
844
    Mixin with export functionality implemented as an admin action.
845
    """
846

847
    #: template for change form
848
    change_form_template = "admin/import_export/change_form.html"
4✔
849

850
    #: Flag to indicate whether to show 'export' button on change form
851
    show_change_form_export = True
4✔
852

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

857
    def change_view(self, request, object_id, form_url="", extra_context=None):
4✔
858
        extra_context = extra_context or {}
4✔
859
        extra_context["show_change_form_export"] = (
4✔
860
            self.show_change_form_export and self.has_export_permission(request)
861
        )
862
        return super().change_view(
4✔
863
            request,
864
            object_id,
865
            form_url,
866
            extra_context=extra_context,
867
        )
868

869
    def response_change(self, request, obj):
4✔
870
        # called if the export is triggered from the instance detail page.
871
        if "_export-item" in request.POST:
4✔
872
            return self.export_admin_action(
4✔
873
                request, self.model.objects.filter(pk=obj.pk)
874
            )
875
        return super().response_change(request, obj)
4✔
876

877
    def export_admin_action(self, request, queryset):
4✔
878
        """
879
        Action runs on POST from instance action menu (if enabled).
880
        """
881
        formats = self.get_export_formats()
4✔
882
        if self.is_skip_export_form_from_action_enabled():
4✔
883
            file_format = formats[0]()
4✔
884
            return self._do_file_export(file_format, request, queryset)
4✔
885

886
        form_type = self.get_export_form_class()
4✔
887
        formats = self.get_export_formats()
4✔
888
        export_items = list(queryset.values_list("pk", flat=True))
4✔
889
        form = form_type(
4✔
890
            formats=formats,
891
            resources=self.get_export_resource_classes(request),
892
            initial={"export_items": export_items},
893
        )
894
        # selected items are to be stored as a hidden input on the form
895
        form.fields["export_items"] = MultipleChoiceField(
4✔
896
            widget=MultipleHiddenInput, required=False, choices=export_items
897
        )
898
        context = self.init_request_context_data(request, form)
4✔
899

900
        # this is necessary to render the FORM action correctly
901
        # i.e. so the POST goes to the correct URL
902
        export_url = reverse(
4✔
903
            "%s:%s_%s_export"
904
            % (
905
                self.admin_site.name,
906
                self.model._meta.app_label,
907
                self.model._meta.model_name,
908
            )
909
        )
910

911
        # Preserve admin changelist filters by including request GET parameters
912
        # This fixes issue #2097 where applied filters are lost during export
913
        if request.GET:
4✔
914
            export_url += "?" + urlencode(request.GET)
4✔
915

916
        context["export_url"] = export_url
4✔
917

918
        return render(request, "admin/import_export/export.html", context=context)
4✔
919

920
    def get_actions(self, request):
4✔
921
        """
922
        Adds the export action to the list of available actions.
923
        """
924
        actions = super().get_actions(request)
4✔
925
        if self.has_export_permission(request):
4✔
926
            actions.update(
4✔
927
                export_admin_action=(
928
                    type(self).export_admin_action,
929
                    "export_admin_action",
930
                    _("Export selected %(verbose_name_plural)s"),
931
                )
932
            )
933
        return actions
4✔
934

935

936
class ExportActionModelAdmin(ExportActionMixin, admin.ModelAdmin):
4✔
937
    """
938
    Subclass of ModelAdmin with export functionality implemented as an
939
    admin action.
940
    """
941

942

943
class ImportExportActionModelAdmin(ImportMixin, ExportActionModelAdmin):
4✔
944
    """
945
    Subclass of ExportActionModelAdmin with import/export functionality.
946
    Export functionality is implemented as an admin action.
947
    """
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

© 2026 Coveralls, Inc