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

django-import-export / django-import-export / 18074956828

28 Sep 2025 01:27PM UTC coverage: 99.957% (-0.04%) from 100.0%
18074956828

push

github

web-flow
Fix Admin integration form name collision (#2108)

* basic working version

* updated form.data param

* fixed import path

* removed Author.resource field

* added test field to Author 'resource'

* added constants module

* updated prefix

* removed some duplication

* modification to prefix

* added id attr for resource

* updated docs

18 of 18 new or added lines in 4 files covered. (100.0%)

1 existing line in 1 file now uncovered.

2329 of 2330 relevant lines covered (99.96%)

3.99 hits per line

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

99.74
/import_export/admin.py
1
import logging
4✔
2
import warnings
4✔
3

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

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

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

31

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

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

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

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

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

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

69

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

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

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

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

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

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

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

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

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

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

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

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

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

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

178
            tmp_storage.remove()
4✔
179

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

440
        context = self.get_import_context_data()
4✔
441

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

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

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

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

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

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

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

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

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

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

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

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

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

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

599

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

847

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

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

858

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

864

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

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

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

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

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

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

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

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

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

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

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

952

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

959

960
class ImportExportActionModelAdmin(ImportMixin, ExportActionModelAdmin):
4✔
961
    """
962
    Subclass of ExportActionModelAdmin with import/export functionality.
963
    Export functionality is implemented as an admin action.
964
    """
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